From 8271fa6880561bba331370158ca8d848bb419058 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Fri, 13 Sep 2024 17:11:34 -0400 Subject: [PATCH 01/28] Adds submission-core, tests pass 88% coverage --- poetry.lock | 1928 +++++++++++++++++ pyproject.toml | 56 + schemas/openapiv1.yaml | 0 src/arxiv/submission/__init__.py | 257 +++ src/arxiv/submission/auth.py | 46 + src/arxiv/submission/config.py | 299 +++ src/arxiv/submission/core.py | 201 ++ src/arxiv/submission/domain/__init__.py | 12 + src/arxiv/submission/domain/agent.py | 142 ++ src/arxiv/submission/domain/annotation.py | 115 + src/arxiv/submission/domain/compilation.py | 166 ++ src/arxiv/submission/domain/event/__init__.py | 1354 ++++++++++++ src/arxiv/submission/domain/event/base.py | 353 +++ src/arxiv/submission/domain/event/flag.py | 256 +++ src/arxiv/submission/domain/event/process.py | 51 + src/arxiv/submission/domain/event/proposal.py | 148 ++ src/arxiv/submission/domain/event/request.py | 224 ++ .../submission/domain/event/tests/__init__.py | 0 .../event/tests/test_abstract_cleanup.py | 52 + .../event/tests/test_event_construction.py | 45 + .../domain/event/tests/test_hooks.py | 61 + src/arxiv/submission/domain/event/util.py | 27 + .../submission/domain/event/validators.py | 128 ++ .../domain/event/versioning/__init__.py | 131 ++ .../domain/event/versioning/_base.py | 121 ++ .../domain/event/versioning/tests/__init__.py | 1 + .../event/versioning/tests/test_example.py | 15 + .../event/versioning/tests/test_versioning.py | 136 ++ .../event/versioning/version_0_0_0_example.py | 46 + src/arxiv/submission/domain/flag.py | 100 + src/arxiv/submission/domain/meta.py | 20 + src/arxiv/submission/domain/preview.py | 24 + src/arxiv/submission/domain/process.py | 48 + src/arxiv/submission/domain/proposal.py | 65 + src/arxiv/submission/domain/submission.py | 534 +++++ src/arxiv/submission/domain/tests/__init__.py | 1 + .../submission/domain/tests/test_events.py | 1016 +++++++++ src/arxiv/submission/domain/uploads.py | 153 ++ src/arxiv/submission/domain/util.py | 19 + src/arxiv/submission/exceptions.py | 28 + src/arxiv/submission/process/__init__.py | 2 + .../submission/process/process_source.py | 504 +++++ src/arxiv/submission/process/tests.py | 537 +++++ src/arxiv/submission/schedule.py | 82 + src/arxiv/submission/serializer.py | 97 + src/arxiv/submission/services/__init__.py | 8 + .../submission/services/classic/__init__.py | 719 ++++++ .../submission/services/classic/bootstrap.py | 155 ++ .../submission/services/classic/event.py | 76 + .../submission/services/classic/exceptions.py | 21 + .../services/classic/interpolate.py | 304 +++ src/arxiv/submission/services/classic/load.py | 226 ++ src/arxiv/submission/services/classic/log.py | 141 ++ .../submission/services/classic/models.py | 909 ++++++++ .../submission/services/classic/patch.py | 122 ++ .../submission/services/classic/proposal.py | 64 + .../services/classic/tests/__init__.py | 11 + .../services/classic/tests/test_admin_log.py | 97 + .../classic/tests/test_get_licenses.py | 45 + .../classic/tests/test_get_submission.py | 248 +++ .../classic/tests/test_store_annotations.py | 1 + .../classic/tests/test_store_event.py | 318 +++ .../classic/tests/test_store_proposals.py | 139 ++ .../submission/services/classic/tests/util.py | 24 + src/arxiv/submission/services/classic/util.py | 115 + .../services/classifier/__init__.py | 16 + .../services/classifier/classifier.py | 108 + .../services/classifier/tests/__init__.py | 1 + .../classifier/tests/data/linenos.json | 1 + .../tests/data/sampleFailedCyrillic.json | 21 + .../classifier/tests/data/sampleResponse.json | 74 + .../services/classifier/tests/tests.py | 228 ++ .../submission/services/compiler/__init__.py | 3 + .../submission/services/compiler/compiler.py | 249 +++ .../submission/services/compiler/tests.py | 237 ++ .../services/filemanager/__init__.py | 3 + .../services/filemanager/filemanager.py | 336 +++ .../services/filemanager/tests/__init__.py | 1 + .../services/filemanager/tests/data/test.txt | 9 + .../services/filemanager/tests/data/test.zip | Bin 0 -> 896 bytes .../tests/test_filemanager_integration.py | 255 +++ .../submission/services/plaintext/__init__.py | 3 + .../services/plaintext/plaintext.py | 167 ++ .../submission/services/plaintext/tests.py | 827 +++++++ .../submission/services/preview/__init__.py | 3 + .../submission/services/preview/preview.py | 218 ++ .../submission/services/preview/tests.py | 142 ++ .../submission/services/stream/__init__.py | 3 + .../submission/services/stream/stream.py | 128 ++ src/arxiv/submission/services/util.py | 47 + .../submission-core/confirmation-email.html | 28 + .../submission-core/confirmation-email.txt | 38 + src/arxiv/submission/tests/#util.py# | 74 + src/arxiv/submission/tests/__init__.py | 1 + .../submission/tests/annotations/__init__.py | 0 src/arxiv/submission/tests/api/__init__.py | 0 src/arxiv/submission/tests/api/test_api.py | 183 ++ .../submission/tests/classic/__init__.py | 0 .../tests/classic/test_classic_integration.py | 1064 +++++++++ .../submission/tests/examples/__init__.py | 7 + .../examples/test_01_working_submission.py | 180 ++ .../examples/test_02_finalized_submission.py | 200 ++ .../examples/test_03_on_hold_submission.py | 205 ++ .../examples/test_04_published_submission.py | 531 +++++ .../examples/test_05_working_replacement.py | 465 ++++ .../test_06_second_version_published.py | 420 ++++ .../examples/test_07_cross_list_requested.py | 1086 ++++++++++ .../examples/test_10_abandon_submission.py | 682 ++++++ .../submission/tests/schedule/__init__.py | 0 .../tests/schedule/test_schedule.py | 62 + .../submission/tests/serializer/__init__.py | 0 .../tests/serializer/test_serializer.py | 151 ++ src/arxiv/submission/tests/util.py | 74 + 113 files changed, 21875 insertions(+) create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 schemas/openapiv1.yaml create mode 100644 src/arxiv/submission/__init__.py create mode 100644 src/arxiv/submission/auth.py create mode 100644 src/arxiv/submission/config.py create mode 100644 src/arxiv/submission/core.py create mode 100644 src/arxiv/submission/domain/__init__.py create mode 100644 src/arxiv/submission/domain/agent.py create mode 100644 src/arxiv/submission/domain/annotation.py create mode 100644 src/arxiv/submission/domain/compilation.py create mode 100644 src/arxiv/submission/domain/event/__init__.py create mode 100644 src/arxiv/submission/domain/event/base.py create mode 100644 src/arxiv/submission/domain/event/flag.py create mode 100644 src/arxiv/submission/domain/event/process.py create mode 100644 src/arxiv/submission/domain/event/proposal.py create mode 100644 src/arxiv/submission/domain/event/request.py create mode 100644 src/arxiv/submission/domain/event/tests/__init__.py create mode 100644 src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py create mode 100644 src/arxiv/submission/domain/event/tests/test_event_construction.py create mode 100644 src/arxiv/submission/domain/event/tests/test_hooks.py create mode 100644 src/arxiv/submission/domain/event/util.py create mode 100644 src/arxiv/submission/domain/event/validators.py create mode 100644 src/arxiv/submission/domain/event/versioning/__init__.py create mode 100644 src/arxiv/submission/domain/event/versioning/_base.py create mode 100644 src/arxiv/submission/domain/event/versioning/tests/__init__.py create mode 100644 src/arxiv/submission/domain/event/versioning/tests/test_example.py create mode 100644 src/arxiv/submission/domain/event/versioning/tests/test_versioning.py create mode 100644 src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py create mode 100644 src/arxiv/submission/domain/flag.py create mode 100644 src/arxiv/submission/domain/meta.py create mode 100644 src/arxiv/submission/domain/preview.py create mode 100644 src/arxiv/submission/domain/process.py create mode 100644 src/arxiv/submission/domain/proposal.py create mode 100644 src/arxiv/submission/domain/submission.py create mode 100644 src/arxiv/submission/domain/tests/__init__.py create mode 100644 src/arxiv/submission/domain/tests/test_events.py create mode 100644 src/arxiv/submission/domain/uploads.py create mode 100644 src/arxiv/submission/domain/util.py create mode 100644 src/arxiv/submission/exceptions.py create mode 100644 src/arxiv/submission/process/__init__.py create mode 100644 src/arxiv/submission/process/process_source.py create mode 100644 src/arxiv/submission/process/tests.py create mode 100644 src/arxiv/submission/schedule.py create mode 100644 src/arxiv/submission/serializer.py create mode 100644 src/arxiv/submission/services/__init__.py create mode 100644 src/arxiv/submission/services/classic/__init__.py create mode 100644 src/arxiv/submission/services/classic/bootstrap.py create mode 100644 src/arxiv/submission/services/classic/event.py create mode 100644 src/arxiv/submission/services/classic/exceptions.py create mode 100644 src/arxiv/submission/services/classic/interpolate.py create mode 100644 src/arxiv/submission/services/classic/load.py create mode 100644 src/arxiv/submission/services/classic/log.py create mode 100644 src/arxiv/submission/services/classic/models.py create mode 100644 src/arxiv/submission/services/classic/patch.py create mode 100644 src/arxiv/submission/services/classic/proposal.py create mode 100644 src/arxiv/submission/services/classic/tests/__init__.py create mode 100644 src/arxiv/submission/services/classic/tests/test_admin_log.py create mode 100644 src/arxiv/submission/services/classic/tests/test_get_licenses.py create mode 100644 src/arxiv/submission/services/classic/tests/test_get_submission.py create mode 100644 src/arxiv/submission/services/classic/tests/test_store_annotations.py create mode 100644 src/arxiv/submission/services/classic/tests/test_store_event.py create mode 100644 src/arxiv/submission/services/classic/tests/test_store_proposals.py create mode 100644 src/arxiv/submission/services/classic/tests/util.py create mode 100644 src/arxiv/submission/services/classic/util.py create mode 100644 src/arxiv/submission/services/classifier/__init__.py create mode 100644 src/arxiv/submission/services/classifier/classifier.py create mode 100644 src/arxiv/submission/services/classifier/tests/__init__.py create mode 100644 src/arxiv/submission/services/classifier/tests/data/linenos.json create mode 100644 src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json create mode 100644 src/arxiv/submission/services/classifier/tests/data/sampleResponse.json create mode 100644 src/arxiv/submission/services/classifier/tests/tests.py create mode 100644 src/arxiv/submission/services/compiler/__init__.py create mode 100644 src/arxiv/submission/services/compiler/compiler.py create mode 100644 src/arxiv/submission/services/compiler/tests.py create mode 100644 src/arxiv/submission/services/filemanager/__init__.py create mode 100644 src/arxiv/submission/services/filemanager/filemanager.py create mode 100644 src/arxiv/submission/services/filemanager/tests/__init__.py create mode 100644 src/arxiv/submission/services/filemanager/tests/data/test.txt create mode 100644 src/arxiv/submission/services/filemanager/tests/data/test.zip create mode 100644 src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py create mode 100644 src/arxiv/submission/services/plaintext/__init__.py create mode 100644 src/arxiv/submission/services/plaintext/plaintext.py create mode 100644 src/arxiv/submission/services/plaintext/tests.py create mode 100644 src/arxiv/submission/services/preview/__init__.py create mode 100644 src/arxiv/submission/services/preview/preview.py create mode 100644 src/arxiv/submission/services/preview/tests.py create mode 100644 src/arxiv/submission/services/stream/__init__.py create mode 100644 src/arxiv/submission/services/stream/stream.py create mode 100644 src/arxiv/submission/services/util.py create mode 100644 src/arxiv/submission/templates/submission-core/confirmation-email.html create mode 100644 src/arxiv/submission/templates/submission-core/confirmation-email.txt create mode 100644 src/arxiv/submission/tests/#util.py# create mode 100644 src/arxiv/submission/tests/__init__.py create mode 100644 src/arxiv/submission/tests/annotations/__init__.py create mode 100644 src/arxiv/submission/tests/api/__init__.py create mode 100644 src/arxiv/submission/tests/api/test_api.py create mode 100644 src/arxiv/submission/tests/classic/__init__.py create mode 100644 src/arxiv/submission/tests/classic/test_classic_integration.py create mode 100644 src/arxiv/submission/tests/examples/__init__.py create mode 100644 src/arxiv/submission/tests/examples/test_01_working_submission.py create mode 100644 src/arxiv/submission/tests/examples/test_02_finalized_submission.py create mode 100644 src/arxiv/submission/tests/examples/test_03_on_hold_submission.py create mode 100644 src/arxiv/submission/tests/examples/test_04_published_submission.py create mode 100644 src/arxiv/submission/tests/examples/test_05_working_replacement.py create mode 100644 src/arxiv/submission/tests/examples/test_06_second_version_published.py create mode 100644 src/arxiv/submission/tests/examples/test_07_cross_list_requested.py create mode 100644 src/arxiv/submission/tests/examples/test_10_abandon_submission.py create mode 100644 src/arxiv/submission/tests/schedule/__init__.py create mode 100644 src/arxiv/submission/tests/schedule/test_schedule.py create mode 100644 src/arxiv/submission/tests/serializer/__init__.py create mode 100644 src/arxiv/submission/tests/serializer/test_serializer.py create mode 100644 src/arxiv/submission/tests/util.py diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2830957 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1928 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "arxiv-auth" +version = "1.1.0" +description = "Auth libraries for arXiv." +optional = false +python-versions = "^3.10" +files = [] +develop = false + +[package.dependencies] +arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", rev = "1.0.1"} +flask = "*" +flask-sqlalchemy = "*" +mysqlclient = "*" +pydantic = "^1.0" +pyjwt = "*" +python-dateutil = "*" +redis = "==2.10.6" +redis-py-cluster = "==1.3.6" +sqlalchemy = "*" + +[package.source] +type = "git" +url = "https://github.com/arXiv/arxiv-auth.git" +reference = "develop" +resolved_reference = "241169e13aa74b2fad57a8ba05ec3305ccff5ea0" +subdirectory = "arxiv-auth" + +[[package]] +name = "arxiv-base" +version = "1.0.0a5" +description = "Common code for arXiv NG" +optional = false +python-versions = "^3.10" +files = [] +develop = false + +[package.dependencies] +bleach = "*" +boto3 = "==1.*" +flask = "~=2.2" +flask-s3 = "*" +google-cloud-storage = "^2.5.0" +markupsafe = "*" +pytz = "*" +retry = "*" +semantic-version = "*" +typing-extensions = "*" +wtforms = "*" + +[package.source] +type = "git" +url = "https://github.com/arXiv/arxiv-base.git" +reference = "1.0.1" +resolved_reference = "45c557e75bc6b645dded63811770b87c405bda3e" + +[[package]] +name = "astroid" +version = "1.6.6" +description = "A abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "astroid-1.6.6-py2.py3-none-any.whl", hash = "sha256:87de48a92e29cedf7210ffa853d11441e7ad94cb47bacd91b023499b51cbc756"}, + {file = "astroid-1.6.6.tar.gz", hash = "sha256:d25869fc7f44f1d9fb7d24fd7ea0639656f5355fc3089cd1f3d18c6ec6b124c7"}, +] + +[package.dependencies] +lazy-object-proxy = "*" +six = "*" +wrapt = "*" + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.2" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +files = [ + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09e70210726a70f3dd02ab9725bf2fcf469bda6d7554ea955588202e43e45b7d"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ec971f93353e0ee957b3bbb037d58371331eedb9bee1b6676a866f8be97289a4"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:191b0d327838eb21818e94a66b89118c086ada8f77ac9e6161980ef486fe0cbb"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00441807d47dec7b89acafaa6570f561c43f5c7b7934d86f101b783a365a0f0c"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0af8719e161ce2fa5f5e426cceef1ff04b611c69a61636c8a7bf25d687cfa0"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5afc32e1cdac293b054af04187d4adafcaceca99e12e5ff7807aee08074d85cb"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70b044fdd274e32ece726d30b1728b4a21bc78fed0be6294091c6f04228b39ec"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6f493622b06e23e10646df7ea23e0d8350e8b1caccb5509ea82f8c3e64db32c7"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55f59c88511dd15dabccf7916cbf23f8610203ac026454588084ddabf46127ee"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:65ca1f21319d78145456a70301396483ceebf078353641233494ea548ccc47db"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:79fc695afd66989f28e73de0ad91019abad789045577180dd482b6ede5bdca1e"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019a87bd234734c2badb4c3e1ce4e807c5f2081f398a45a320e0c4919e5cee13"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea2b77e8810b691f1dd347d5c3d4ad829d18a9e81a04a0ebbc958d431967db31"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:944c987b777d7a81d97c94cdee2a8597bf6bdc94090094689456d3b02760cb73"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:30a2ab8c1fe4eb0013e7fcca29906fbe54e89f9120731ea71032b048dcf2fa17"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e23b602892827e15b1b4f94c61d4872b03b5d13417344d9a8daec80277244a32"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64ec1ee18bc839847b067ab21a34a27e0d2cc4c6d041e4b05218cf6fed787740"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:54a3df9d6ae0e64b7677b9e3bba4fc7dce3ad56a3fa6bd66fb26796f8911de67"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:e54fa5663efcba6122bca037fd49220b7311e94cf6cc72e2f2a6f5d05c700bef"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ecff906ed4eb19808d8e4f0b141c14a1963d3688ba318c9e00aa7da7f71301"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e85f1ad56e2bcb24408e420de5508be47e54b0912ebe1325134e71837ec23a08"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36d5cbece09dff2a3f8f517f3cda64f2ccec56db07808714b1f122326cd76fbd"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d47e186dcc366e6063248730a137a90de0472b2aaa5047ef39104fcacbcbcdbe"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:3e9c81c6acc21953ffa9a627f15c4afcdbce6e456ca1d03e0d6dbf131429bd56"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5a2574f4b542b9679db2b8a786c779249d2d5057dad01f9433cfb79a921da92c"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:e62aa2eb6dc87a76a29b88601747925db439f793de7a8d2bbca4355e805088a6"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:964ec2d2c23908e96f1064560def1547b355e33e7c1ab418265e7e6242d25841"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8003f0cebeb6a5c47a1a871d0d09897d3dd54a9e1bcbe313f3e0463d470eed97"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c88e6660e1fb96476cb9df17d6f5002a2fb5c87546d62b2daa3642aa537e144"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7124cda6acdc66755df916c1f52b4e2e9cad85591d40bcd4a80341144fd98b32"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c2b0a4a407479964b3f79fde080aad066fe64a350a3fcbb729d3c44b0db21240"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:5616519470bc8131429266a869c3c5eeee5817a9a8357e2dd9c521383b774d1b"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2eb563509f19e803dbbef3e4901d9553c9c3ea2b73c8d8fb85219fc57f16787a"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d37d2f4238e0f412e56fe2c41e8e60bda93be0230d0ee846823b54254ccb95e0"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:7dcefbba71194c73b3b26593c2ea4ad254b19084d0eb83e98e2541651a692703"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:352f6b793cb402cc62c5b60ceab13d30c06fad1372869c716d4d07927b5c7c43"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d6a21b482001a9ea44f277dc21d9fb6590e543146aaabe816407d1b87cf41b"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f97285e80ea192357380cfd2fb2dce056ec65672597172f3af549dcf5d019b1e"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a5cfff34bf80f0cd2771da88bd898be1fa60250d6f2dd9e4a59885dbcb7aa7c"}, + {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:ed392607d457b1ed50a88dcaf459e11d81c30a2f2d8dab818a1564de6897e76f"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0f24d2c596991e39dfaa60c685b8c69bc9b1da77e9baf2c453882adeec483b"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0083552588270acfaa31ac8de81b29786a1515d7608ff11ccdfcdffc2486212e"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f367b7d7bc00aa6738c95eb48b90817f7f9bd9c61592ceedda29ece97983ee3f"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e0914e357d8559f1821e46fd5ef5d3bd22ec568125ba9e680b6e70cdc352910"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d5a7cf9cdee221b7721544f424c69747a04091cbff53aa6ae8454644b59f9"}, + {file = "backports_datetime_fromisoformat-2.0.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a5e4c77a91db6f434c2eec46c0199d3617c19c812f0c74f7ed8e0f9779da9f0"}, + {file = "backports_datetime_fromisoformat-2.0.2.tar.gz", hash = "sha256:142313bde1f93b0ea55f20f5a6ea034f84c79713daeb252dc47d40019db3812f"}, +] + +[[package]] +name = "bleach" +version = "6.1.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6"}, + {file = "bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe"}, +] + +[package.dependencies] +six = ">=1.9.0" +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0,<1.3)"] + +[[package]] +name = "blinker" +version = "1.8.2" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, + {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, +] + +[[package]] +name = "boto3" +version = "1.35.18" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "boto3-1.35.18-py3-none-any.whl", hash = "sha256:71e237d3997cf93425947854d7b121c577944f391ba633afb0659e1015364704"}, + {file = "boto3-1.35.18.tar.gz", hash = "sha256:fd130308f1f49d748a5fc63de92de79a995b51c79af3947ddde8815fcf0684fe"}, +] + +[package.dependencies] +botocore = ">=1.35.18,<1.36.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.10.0,<0.11.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.35.18" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.8" +files = [ + {file = "botocore-1.35.18-py3-none-any.whl", hash = "sha256:1027083aeb1fe74057273410fd768e018e22f85adfbd717b5a69f578f7812b80"}, + {file = "botocore-1.35.18.tar.gz", hash = "sha256:e59da8b91ab06683d2725b6cbbb0383b30c68a241c3c63363f4c5bff59b3c0c0"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.21.5)"] + +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "coveralls" +version = "1.8.0" +description = "Show coverage stats online via coveralls.io" +optional = false +python-versions = "*" +files = [ + {file = "coveralls-1.8.0-py2.py3-none-any.whl", hash = "sha256:a8de28a5f04e418c7142b8ce6588c3a64245b433c458a5871cb043383667e4f2"}, + {file = "coveralls-1.8.0.tar.gz", hash = "sha256:c5e50b73b980d89308816b597e3e7bdeb0adedf831585d5c4ac967d576f8925d"}, +] + +[package.dependencies] +coverage = ">=3.6" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "docker" +version = "7.1.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, +] + +[package.dependencies] +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flask" +version = "2.3.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-2.3.3-py3-none-any.whl", hash = "sha256:f69fcd559dc907ed196ab9df0e48471709175e696d6e698dd4dbe940f96ce66b"}, + {file = "flask-2.3.3.tar.gz", hash = "sha256:09c347a92aa7ff4a8e7f3206795f30d826654baf38b873d0744cd571ca609efc"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=2.3.7" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-s3" +version = "0.3.3" +description = "Seamlessly serve the static files of your Flask app from Amazon S3" +optional = false +python-versions = "*" +files = [ + {file = "Flask-S3-0.3.3.tar.gz", hash = "sha256:1d49061d4b78759df763358a901f4ed32bb43f672c9f8e1ec7226793f6ae0fd2"}, + {file = "Flask_S3-0.3.3-py3-none-any.whl", hash = "sha256:23cbbb1db4c29c313455dbe16f25be078d6318f0a11abcbb610f99e116945b62"}, +] + +[package.dependencies] +Boto3 = ">=1.1.1" +Flask = "*" +six = "*" + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +description = "Add SQLAlchemy support to your Flask application." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"}, + {file = "flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312"}, +] + +[package.dependencies] +flask = ">=2.2.5" +sqlalchemy = ">=2.0.16" + +[[package]] +name = "google-api-core" +version = "2.19.2" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_core-2.19.2-py3-none-any.whl", hash = "sha256:53ec0258f2837dd53bbd3d3df50f5359281b3cc13f800c941dd15a9b5a415af4"}, + {file = "google_api_core-2.19.2.tar.gz", hash = "sha256:ca07de7e8aa1c98a8bfca9321890ad2340ef7f2eb136e558cee68f24b94b0a8f"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-auth" +version = "2.34.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.34.0-py2.py3-none-any.whl", hash = "sha256:72fd4733b80b6d777dcde515628a9eb4a577339437012874ea286bca7261ee65"}, + {file = "google_auth-2.34.0.tar.gz", hash = "sha256:8eb87396435c19b20d32abd2f984e31c191a15284af72eb922f10e5bde9c04cc"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-cloud-core" +version = "2.4.1" +description = "Google Cloud API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, + {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] + +[[package]] +name = "google-cloud-storage" +version = "2.18.2" +description = "Google Cloud Storage API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_storage-2.18.2-py2.py3-none-any.whl", hash = "sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166"}, + {file = "google_cloud_storage-2.18.2.tar.gz", hash = "sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99"}, +] + +[package.dependencies] +google-api-core = ">=2.15.0,<3.0.0dev" +google-auth = ">=2.26.1,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-crc32c = ">=1.0,<2.0dev" +google-resumable-media = ">=2.7.2" +requests = ">=2.18.0,<3.0.0dev" + +[package.extras] +protobuf = ["protobuf (<6.0.0dev)"] +tracing = ["opentelemetry-api (>=1.1.0)"] + +[[package]] +name = "google-crc32c" +version = "1.6.0" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.9" +files = [ + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, + {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, + {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, + {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, + {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, +] + +[package.extras] +testing = ["pytest"] + +[[package]] +name = "google-resumable-media" +version = "2.7.2" +description = "Utilities for Google Media Downloads and Resumable Uploads" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, + {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, +] + +[package.dependencies] +google-crc32c = ">=1.0,<2.0dev" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.65.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis_common_protos-1.65.0-py2.py3-none-any.whl", hash = "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63"}, + {file = "googleapis_common_protos-1.65.0.tar.gz", hash = "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "greenlet" +version = "3.1.0" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a814dc3100e8a046ff48faeaa909e80cdb358411a3d6dd5293158425c684eda8"}, + {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a771dc64fa44ebe58d65768d869fcfb9060169d203446c1d446e844b62bdfdca"}, + {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e49a65d25d7350cca2da15aac31b6f67a43d867448babf997fe83c7505f57bc"}, + {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cd8518eade968bc52262d8c46727cfc0826ff4d552cf0430b8d65aaf50bb91d"}, + {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76dc19e660baea5c38e949455c1181bc018893f25372d10ffe24b3ed7341fb25"}, + {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a5b1c22c82831f56f2f7ad9bbe4948879762fe0d59833a4a71f16e5fa0f682"}, + {file = "greenlet-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2651dfb006f391bcb240635079a68a261b227a10a08af6349cba834a2141efa1"}, + {file = "greenlet-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3e7e6ef1737a819819b1163116ad4b48d06cfdd40352d813bb14436024fcda99"}, + {file = "greenlet-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:ffb08f2a1e59d38c7b8b9ac8083c9c8b9875f0955b1e9b9b9a965607a51f8e54"}, + {file = "greenlet-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9730929375021ec90f6447bff4f7f5508faef1c02f399a1953870cdb78e0c345"}, + {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713d450cf8e61854de9420fb7eea8ad228df4e27e7d4ed465de98c955d2b3fa6"}, + {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c3446937be153718250fe421da548f973124189f18fe4575a0510b5c928f0cc"}, + {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ddc7bcedeb47187be74208bc652d63d6b20cb24f4e596bd356092d8000da6d6"}, + {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44151d7b81b9391ed759a2f2865bbe623ef00d648fed59363be2bbbd5154656f"}, + {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cea1cca3be76c9483282dc7760ea1cc08a6ecec1f0b6ca0a94ea0d17432da19"}, + {file = "greenlet-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:619935a44f414274a2c08c9e74611965650b730eb4efe4b2270f91df5e4adf9a"}, + {file = "greenlet-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:221169d31cada333a0c7fd087b957c8f431c1dba202c3a58cf5a3583ed973e9b"}, + {file = "greenlet-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:01059afb9b178606b4b6e92c3e710ea1635597c3537e44da69f4531e111dd5e9"}, + {file = "greenlet-3.1.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:24fc216ec7c8be9becba8b64a98a78f9cd057fd2dc75ae952ca94ed8a893bf27"}, + {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d07c28b85b350564bdff9f51c1c5007dfb2f389385d1bc23288de51134ca303"}, + {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:243a223c96a4246f8a30ea470c440fe9db1f5e444941ee3c3cd79df119b8eebf"}, + {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26811df4dc81271033a7836bc20d12cd30938e6bd2e9437f56fa03da81b0f8fc"}, + {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9d86401550b09a55410f32ceb5fe7efcd998bd2dad9e82521713cb148a4a15f"}, + {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9c1c4f1748ccac0bae1dbb465fb1a795a75aba8af8ca871503019f4285e2a"}, + {file = "greenlet-3.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cd468ec62257bb4544989402b19d795d2305eccb06cde5da0eb739b63dc04665"}, + {file = "greenlet-3.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a53dfe8f82b715319e9953330fa5c8708b610d48b5c59f1316337302af5c0811"}, + {file = "greenlet-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:28fe80a3eb673b2d5cc3b12eea468a5e5f4603c26aa34d88bf61bba82ceb2f9b"}, + {file = "greenlet-3.1.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:76b3e3976d2a452cba7aa9e453498ac72240d43030fdc6d538a72b87eaff52fd"}, + {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655b21ffd37a96b1e78cc48bf254f5ea4b5b85efaf9e9e2a526b3c9309d660ca"}, + {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6f4c2027689093775fd58ca2388d58789009116844432d920e9147f91acbe64"}, + {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76e5064fd8e94c3f74d9fd69b02d99e3cdb8fc286ed49a1f10b256e59d0d3a0b"}, + {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4bf607f690f7987ab3291406e012cd8591a4f77aa54f29b890f9c331e84989"}, + {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037d9ac99540ace9424cb9ea89f0accfaff4316f149520b4ae293eebc5bded17"}, + {file = "greenlet-3.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:90b5bbf05fe3d3ef697103850c2ce3374558f6fe40fd57c9fac1bf14903f50a5"}, + {file = "greenlet-3.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:726377bd60081172685c0ff46afbc600d064f01053190e4450857483c4d44484"}, + {file = "greenlet-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:d46d5069e2eeda111d6f71970e341f4bd9aeeee92074e649ae263b834286ecc0"}, + {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81eeec4403a7d7684b5812a8aaa626fa23b7d0848edb3a28d2eb3220daddcbd0"}, + {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a3dae7492d16e85ea6045fd11cb8e782b63eac8c8d520c3a92c02ac4573b0a6"}, + {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b5ea3664eed571779403858d7cd0a9b0ebf50d57d2cdeafc7748e09ef8cd81a"}, + {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22f4e26400f7f48faef2d69c20dc055a1f3043d330923f9abe08ea0aecc44df"}, + {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13ff8c8e54a10472ce3b2a2da007f915175192f18e6495bad50486e87c7f6637"}, + {file = "greenlet-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9671e7282d8c6fcabc32c0fb8d7c0ea8894ae85cee89c9aadc2d7129e1a9954"}, + {file = "greenlet-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:184258372ae9e1e9bddce6f187967f2e08ecd16906557c4320e3ba88a93438c3"}, + {file = "greenlet-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:a0409bc18a9f85321399c29baf93545152d74a49d92f2f55302f122007cfda00"}, + {file = "greenlet-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9eb4a1d7399b9f3c7ac68ae6baa6be5f9195d1d08c9ddc45ad559aa6b556bce6"}, + {file = "greenlet-3.1.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a8870983af660798dc1b529e1fd6f1cefd94e45135a32e58bd70edd694540f33"}, + {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfcfb73aed40f550a57ea904629bdaf2e562c68fa1164fa4588e752af6efdc3f"}, + {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9482c2ed414781c0af0b35d9d575226da6b728bd1a720668fa05837184965b7"}, + {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d58ec349e0c2c0bc6669bf2cd4982d2f93bf067860d23a0ea1fe677b0f0b1e09"}, + {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd65695a8df1233309b701dec2539cc4b11e97d4fcc0f4185b4a12ce54db0491"}, + {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:665b21e95bc0fce5cab03b2e1d90ba9c66c510f1bb5fdc864f3a377d0f553f6b"}, + {file = "greenlet-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3c59a06c2c28a81a026ff11fbf012081ea34fb9b7052f2ed0366e14896f0a1d"}, + {file = "greenlet-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415b9494ff6240b09af06b91a375731febe0090218e2898d2b85f9b92abcda0"}, + {file = "greenlet-3.1.0-cp38-cp38-win32.whl", hash = "sha256:1544b8dd090b494c55e60c4ff46e238be44fdc472d2589e943c241e0169bcea2"}, + {file = "greenlet-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:7f346d24d74c00b6730440f5eb8ec3fe5774ca8d1c9574e8e57c8671bb51b910"}, + {file = "greenlet-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:db1b3ccb93488328c74e97ff888604a8b95ae4f35f4f56677ca57a4fc3a4220b"}, + {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44cd313629ded43bb3b98737bba2f3e2c2c8679b55ea29ed73daea6b755fe8e7"}, + {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fad7a051e07f64e297e6e8399b4d6a3bdcad3d7297409e9a06ef8cbccff4f501"}, + {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3967dcc1cd2ea61b08b0b276659242cbce5caca39e7cbc02408222fb9e6ff39"}, + {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d45b75b0f3fd8d99f62eb7908cfa6d727b7ed190737dec7fe46d993da550b81a"}, + {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d004db911ed7b6218ec5c5bfe4cf70ae8aa2223dffbb5b3c69e342bb253cb28"}, + {file = "greenlet-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9505a0c8579899057cbefd4ec34d865ab99852baf1ff33a9481eb3924e2da0b"}, + {file = "greenlet-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fd6e94593f6f9714dbad1aaba734b5ec04593374fa6638df61592055868f8b8"}, + {file = "greenlet-3.1.0-cp39-cp39-win32.whl", hash = "sha256:d0dd943282231480aad5f50f89bdf26690c995e8ff555f26d8a5b9887b559bcc"}, + {file = "greenlet-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:ac0adfdb3a21dc2a24ed728b61e72440d297d0fd3a577389df566651fcd08f97"}, + {file = "greenlet-3.1.0.tar.gz", hash = "sha256:b395121e9bbe8d02a750886f108d540abe66075e61e22f7353d9acb0b81be0f0"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "idna" +version = "3.8" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, + {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isodate" +version = "0.6.1" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = "*" +files = [ + {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, + {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + +[[package]] +name = "jsonschema" +version = "2.6.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = "*" +files = [ + {file = "jsonschema-2.6.0-py2.py3-none-any.whl", hash = "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08"}, + {file = "jsonschema-2.6.0.tar.gz", hash = "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02"}, +] + +[package.extras] +format = ["rfc3987", "strict-rfc3339", "webcolors"] + +[[package]] +name = "lazy-object-proxy" +version = "1.10.0" +description = "A fast and thorough lazy object proxy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "lazy-object-proxy-1.10.0.tar.gz", hash = "sha256:78247b6d45f43a52ef35c25b5581459e85117225408a4128a3daf8bf9648ac69"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:855e068b0358ab916454464a884779c7ffa312b8925c6f7401e952dcf3b89977"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab7004cf2e59f7c2e4345604a3e6ea0d92ac44e1c2375527d56492014e690c3"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0d2fc424e54c70c4bc06787e4072c4f3b1aa2f897dfdc34ce1013cf3ceef05"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e2adb09778797da09d2b5ebdbceebf7dd32e2c96f79da9052b2e87b6ea495895"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b1f711e2c6dcd4edd372cf5dec5c5a30d23bba06ee012093267b3376c079ec83"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-win32.whl", hash = "sha256:76a095cfe6045c7d0ca77db9934e8f7b71b14645f0094ffcd842349ada5c5fb9"}, + {file = "lazy_object_proxy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4f87d4ed9064b2628da63830986c3d2dca7501e6018347798313fcf028e2fd4"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fec03caabbc6b59ea4a638bee5fce7117be8e99a4103d9d5ad77f15d6f81020c"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c83f957782cbbe8136bee26416686a6ae998c7b6191711a04da776dc9e47d4"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009e6bb1f1935a62889ddc8541514b6a9e1fcf302667dcb049a0be5c8f613e56"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75fc59fc450050b1b3c203c35020bc41bd2695ed692a392924c6ce180c6f1dc9"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:782e2c9b2aab1708ffb07d4bf377d12901d7a1d99e5e410d648d892f8967ab1f"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-win32.whl", hash = "sha256:edb45bb8278574710e68a6b021599a10ce730d156e5b254941754a9cc0b17d03"}, + {file = "lazy_object_proxy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:e271058822765ad5e3bca7f05f2ace0de58a3f4e62045a8c90a0dfd2f8ad8cc6"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e98c8af98d5707dcdecc9ab0863c0ea6e88545d42ca7c3feffb6b4d1e370c7ba"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:952c81d415b9b80ea261d2372d2a4a2332a3890c2b83e0535f263ddfe43f0d43"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b39d3a151309efc8cc48675918891b865bdf742a8616a337cb0090791a0de9"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e221060b701e2aa2ea991542900dd13907a5c90fa80e199dbf5a03359019e7a3"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:92f09ff65ecff3108e56526f9e2481b8116c0b9e1425325e13245abfd79bdb1b"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-win32.whl", hash = "sha256:3ad54b9ddbe20ae9f7c1b29e52f123120772b06dbb18ec6be9101369d63a4074"}, + {file = "lazy_object_proxy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:127a789c75151db6af398b8972178afe6bda7d6f68730c057fbbc2e96b08d282"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9e4ed0518a14dd26092614412936920ad081a424bdcb54cc13349a8e2c6d106a"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9e6ed739285919aa9661a5bbed0aaf410aa60231373c5579c6b4801bd883c"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc0a92c02fa1ca1e84fc60fa258458e5bf89d90a1ddaeb8ed9cc3147f417255"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0aefc7591920bbd360d57ea03c995cebc204b424524a5bd78406f6e1b8b2a5d8"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5faf03a7d8942bb4476e3b62fd0f4cf94eaf4618e304a19865abf89a35c0bbee"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-win32.whl", hash = "sha256:e333e2324307a7b5d86adfa835bb500ee70bfcd1447384a822e96495796b0ca4"}, + {file = "lazy_object_proxy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:cb73507defd385b7705c599a94474b1d5222a508e502553ef94114a143ec6696"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:366c32fe5355ef5fc8a232c5436f4cc66e9d3e8967c01fb2e6302fd6627e3d94"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2297f08f08a2bb0d32a4265e98a006643cd7233fb7983032bd61ac7a02956b3b"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18dd842b49456aaa9a7cf535b04ca4571a302ff72ed8740d06b5adcd41fe0757"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:217138197c170a2a74ca0e05bddcd5f1796c735c37d0eee33e43259b192aa424"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a3a87cf1e133e5b1994144c12ca4aa3d9698517fe1e2ca82977781b16955658"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-win32.whl", hash = "sha256:30b339b2a743c5288405aa79a69e706a06e02958eab31859f7f3c04980853b70"}, + {file = "lazy_object_proxy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:a899b10e17743683b293a729d3a11f2f399e8a90c73b089e29f5d0fe3509f0dd"}, + {file = "lazy_object_proxy-1.10.0-pp310.pp311.pp312.pp38.pp39-none-any.whl", hash = "sha256:80fa48bd89c8f2f456fc0765c11c23bf5af827febacd2f523ca5bc1893fcc09d"}, +] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mimesis" +version = "2.1.0" +description = "Mimesis: mock data for developers." +optional = false +python-versions = "*" +files = [ + {file = "mimesis-2.1.0-py3-none-any.whl", hash = "sha256:2a17aa98cc8aff2c0b1828312e213a515030ed57a1c7b61fc07a87150cb0f25f"}, + {file = "mimesis-2.1.0.tar.gz", hash = "sha256:4b856023acdaaefe2e10bbfea9fd4cb6fa9adbbbe9618a8f796aa8887b58e6f2"}, +] + +[[package]] +name = "mypy" +version = "1.11.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "mysqlclient" +version = "2.2.4" +description = "Python interface to MySQL" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mysqlclient-2.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5"}, + {file = "mysqlclient-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711"}, + {file = "mysqlclient-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab"}, + {file = "mysqlclient-2.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:3c318755e06df599338dad7625f884b8a71fcf322a9939ef78c9b3db93e1de7a"}, + {file = "mysqlclient-2.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:9d4c015480c4a6b2b1602eccd9846103fc70606244788d04aa14b31c4bd1f0e2"}, + {file = "mysqlclient-2.2.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54"}, + {file = "mysqlclient-2.2.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4e80dcad884dd6e14949ac6daf769123223a52a6805345608bf49cdaf7bc8b3a"}, + {file = "mysqlclient-2.2.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9d3310295cb682232cadc28abd172f406c718b9ada41d2371259098ae37779d3"}, + {file = "mysqlclient-2.2.4.tar.gz", hash = "sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41"}, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.1.2" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*" +files = [ + {file = "openapi-schema-validator-0.1.2.tar.gz", hash = "sha256:c1596cae94f0319a68e331e823ca1adf763b1823841e8b6b03d09ea486e44e76"}, + {file = "openapi_schema_validator-0.1.2-py2-none-any.whl", hash = "sha256:ba27b42454d97d0d46151172c2d70b3027464bdd720060c1e8ebb4b29a255e6d"}, + {file = "openapi_schema_validator-0.1.2-py3-none-any.whl", hash = "sha256:4b32307ccd048c82a447088ba72a9f00e1a8607650096f0839a6ca76eecb16c5"}, +] + +[package.dependencies] +isodate = "*" +jsonschema = "*" +six = "*" +strict-rfc3339 = "*" + +[[package]] +name = "openapi-spec-validator" +version = "0.3.1" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3.0 spec validator" +optional = false +python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*" +files = [ + {file = "openapi-spec-validator-0.3.1.tar.gz", hash = "sha256:3d70e6592754799f7e77a45b98c6a91706bdd309a425169d17d8e92173e198a2"}, + {file = "openapi_spec_validator-0.3.1-py2-none-any.whl", hash = "sha256:0a7da925bad4576f4518f77302c0b1990adb2fbcbe7d63fb4ed0de894cad8bdd"}, + {file = "openapi_spec_validator-0.3.1-py3-none-any.whl", hash = "sha256:ba28b06e63274f2bc6de995a07fb572c657e534425b5baf68d9f7911efe6929f"}, +] + +[package.dependencies] +jsonschema = "*" +openapi-schema-validator = "*" +PyYAML = ">=5.1" +six = "*" + +[package.extras] +dev = ["pre-commit"] +requests = ["requests"] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "proto-plus" +version = "1.24.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, + {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.28.1" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.28.1-cp310-abi3-win32.whl", hash = "sha256:fc063acaf7a3d9ca13146fefb5b42ac94ab943ec6e978f543cd5637da2d57957"}, + {file = "protobuf-5.28.1-cp310-abi3-win_amd64.whl", hash = "sha256:4c7f5cb38c640919791c9f74ea80c5b82314c69a8409ea36f2599617d03989af"}, + {file = "protobuf-5.28.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4304e4fceb823d91699e924a1fdf95cde0e066f3b1c28edb665bda762ecde10f"}, + {file = "protobuf-5.28.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:0dfd86d2b5edf03d91ec2a7c15b4e950258150f14f9af5f51c17fa224ee1931f"}, + {file = "protobuf-5.28.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:51f09caab818707ab91cf09cc5c156026599cf05a4520779ccbf53c1b352fb25"}, + {file = "protobuf-5.28.1-cp38-cp38-win32.whl", hash = "sha256:1b04bde117a10ff9d906841a89ec326686c48ececeb65690f15b8cabe7149495"}, + {file = "protobuf-5.28.1-cp38-cp38-win_amd64.whl", hash = "sha256:cabfe43044ee319ad6832b2fda332646f9ef1636b0130186a3ae0a52fc264bb4"}, + {file = "protobuf-5.28.1-cp39-cp39-win32.whl", hash = "sha256:4b4b9a0562a35773ff47a3df823177ab71a1f5eb1ff56d8f842b7432ecfd7fd2"}, + {file = "protobuf-5.28.1-cp39-cp39-win_amd64.whl", hash = "sha256:f24e5d70e6af8ee9672ff605d5503491635f63d5db2fffb6472be78ba62efd8f"}, + {file = "protobuf-5.28.1-py3-none-any.whl", hash = "sha256:c529535e5c0effcf417682563719e5d8ac8d2b93de07a56108b4c2d436d7a29a"}, + {file = "protobuf-5.28.1.tar.gz", hash = "sha256:42597e938f83bb7f3e4b35f03aa45208d49ae8d5bcb4bc10b9fc825e0ab5e423"}, +] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + +[[package]] +name = "pydantic" +version = "1.10.18" +description = "Data validation and settings management using python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, + {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, + {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, + {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, + {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, + {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, + {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, + {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, + {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, + {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pydocstyle" +version = "3.0.0" +description = "Python docstring style checker" +optional = false +python-versions = "*" +files = [ + {file = "pydocstyle-3.0.0-py2-none-any.whl", hash = "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8"}, + {file = "pydocstyle-3.0.0-py3-none-any.whl", hash = "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039"}, + {file = "pydocstyle-3.0.0.tar.gz", hash = "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4"}, +] + +[package.dependencies] +six = "*" +snowballstemmer = "*" + +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pylint" +version = "1.9.4" +description = "python code static checker" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pylint-1.9.4-py2.py3-none-any.whl", hash = "sha256:02c2b6d268695a8b64ad61847f92e611e6afcff33fd26c3a2125370c4662905d"}, + {file = "pylint-1.9.4.tar.gz", hash = "sha256:ee1e85575587c5b58ddafa25e1c1b01691ef172e139fc25585e5d3f02451da93"}, +] + +[package.dependencies] +astroid = ">=1.6,<2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +isort = ">=4.2.5" +mccabe = "*" +six = "*" + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2018.7" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2018.7-py2.py3-none-any.whl", hash = "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6"}, + {file = "pytz-2018.7.tar.gz", hash = "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca"}, +] + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "redis" +version = "2.10.6" +description = "Python client for Redis key-value store" +optional = false +python-versions = "*" +files = [ + {file = "redis-2.10.6-py2.py3-none-any.whl", hash = "sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb"}, + {file = "redis-2.10.6.tar.gz", hash = "sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"}, +] + +[[package]] +name = "redis-py-cluster" +version = "1.3.6" +description = "Library for communicating with Redis Clusters. Built on top of redis-py lib" +optional = false +python-versions = "*" +files = [ + {file = "redis-py-cluster-1.3.6.tar.gz", hash = "sha256:7db54b1de60bd34da3806676b112f07fc9afae556d8260ac02c3335d574ee42c"}, +] + +[package.dependencies] +redis = "2.10.6" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "retry" +version = "0.9.2" +description = "Easy to use retry decorator." +optional = false +python-versions = "*" +files = [ + {file = "retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606"}, + {file = "retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4"}, +] + +[package.dependencies] +decorator = ">=3.4.2" +py = ">=1.4.26,<2.0.0" + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "s3transfer" +version = "0.10.2" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.8" +files = [ + {file = "s3transfer-0.10.2-py3-none-any.whl", hash = "sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69"}, + {file = "s3transfer-0.10.2.tar.gz", hash = "sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6"}, +] + +[package.dependencies] +botocore = ">=1.33.2,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] + +[[package]] +name = "semantic-version" +version = "2.10.0" +description = "A library implementing the 'SemVer' scheme." +optional = false +python-versions = ">=2.7" +files = [ + {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, + {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, +] + +[package.extras] +dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme"] + +[[package]] +name = "semver" +version = "3.0.2" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, + {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.34" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:95d0b2cf8791ab5fb9e3aa3d9a79a0d5d51f55b6357eecf532a120ba3b5524db"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:243f92596f4fd4c8bd30ab8e8dd5965afe226363d75cab2468f2c707f64cd83b"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ea54f7300553af0a2a7235e9b85f4204e1fc21848f917a3213b0e0818de9a24"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:173f5f122d2e1bff8fbd9f7811b7942bead1f5e9f371cdf9e670b327e6703ebd"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:196958cde924a00488e3e83ff917be3b73cd4ed8352bbc0f2989333176d1c54d"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd90c221ed4e60ac9d476db967f436cfcecbd4ef744537c0f2d5291439848768"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-win32.whl", hash = "sha256:3166dfff2d16fe9be3241ee60ece6fcb01cf8e74dd7c5e0b64f8e19fab44911b"}, + {file = "SQLAlchemy-2.0.34-cp310-cp310-win_amd64.whl", hash = "sha256:6831a78bbd3c40f909b3e5233f87341f12d0b34a58f14115c9e94b4cdaf726d3"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7db3db284a0edaebe87f8f6642c2b2c27ed85c3e70064b84d1c9e4ec06d5d84"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:430093fce0efc7941d911d34f75a70084f12f6ca5c15d19595c18753edb7c33b"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79cb400c360c7c210097b147c16a9e4c14688a6402445ac848f296ade6283bbc"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb1b30f31a36c7f3fee848391ff77eebdd3af5750bf95fbf9b8b5323edfdb4ec"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fddde2368e777ea2a4891a3fb4341e910a056be0bb15303bf1b92f073b80c02"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80bd73ea335203b125cf1d8e50fef06be709619eb6ab9e7b891ea34b5baa2287"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-win32.whl", hash = "sha256:6daeb8382d0df526372abd9cb795c992e18eed25ef2c43afe518c73f8cccb721"}, + {file = "SQLAlchemy-2.0.34-cp311-cp311-win_amd64.whl", hash = "sha256:5bc08e75ed11693ecb648b7a0a4ed80da6d10845e44be0c98c03f2f880b68ff4"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:53e68b091492c8ed2bd0141e00ad3089bcc6bf0e6ec4142ad6505b4afe64163e"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bcd18441a49499bf5528deaa9dee1f5c01ca491fc2791b13604e8f972877f812"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165bbe0b376541092bf49542bd9827b048357f4623486096fc9aaa6d4e7c59a2"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3330415cd387d2b88600e8e26b510d0370db9b7eaf984354a43e19c40df2e2b"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97b850f73f8abbffb66ccbab6e55a195a0eb655e5dc74624d15cff4bfb35bd74"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee4c6917857fd6121ed84f56d1dc78eb1d0e87f845ab5a568aba73e78adf83"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-win32.whl", hash = "sha256:fbb034f565ecbe6c530dff948239377ba859420d146d5f62f0271407ffb8c580"}, + {file = "SQLAlchemy-2.0.34-cp312-cp312-win_amd64.whl", hash = "sha256:707c8f44931a4facd4149b52b75b80544a8d824162602b8cd2fe788207307f9a"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:24af3dc43568f3780b7e1e57c49b41d98b2d940c1fd2e62d65d3928b6f95f021"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60ed6ef0a35c6b76b7640fe452d0e47acc832ccbb8475de549a5cc5f90c2c06"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413c85cd0177c23e32dee6898c67a5f49296640041d98fddb2c40888fe4daa2e"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:25691f4adfb9d5e796fd48bf1432272f95f4bbe5f89c475a788f31232ea6afba"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:526ce723265643dbc4c7efb54f56648cc30e7abe20f387d763364b3ce7506c82"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-win32.whl", hash = "sha256:13be2cc683b76977a700948411a94c67ad8faf542fa7da2a4b167f2244781cf3"}, + {file = "SQLAlchemy-2.0.34-cp37-cp37m-win_amd64.whl", hash = "sha256:e54ef33ea80d464c3dcfe881eb00ad5921b60f8115ea1a30d781653edc2fd6a2"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:43f28005141165edd11fbbf1541c920bd29e167b8bbc1fb410d4fe2269c1667a"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b68094b165a9e930aedef90725a8fcfafe9ef95370cbb54abc0464062dbf808f"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1e03db964e9d32f112bae36f0cc1dcd1988d096cfd75d6a588a3c3def9ab2b"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:203d46bddeaa7982f9c3cc693e5bc93db476ab5de9d4b4640d5c99ff219bee8c"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ae92bebca3b1e6bd203494e5ef919a60fb6dfe4d9a47ed2453211d3bd451b9f5"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9661268415f450c95f72f0ac1217cc6f10256f860eed85c2ae32e75b60278ad8"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-win32.whl", hash = "sha256:895184dfef8708e15f7516bd930bda7e50ead069280d2ce09ba11781b630a434"}, + {file = "SQLAlchemy-2.0.34-cp38-cp38-win_amd64.whl", hash = "sha256:6e7cde3a2221aa89247944cafb1b26616380e30c63e37ed19ff0bba5e968688d"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbcdf987f3aceef9763b6d7b1fd3e4ee210ddd26cac421d78b3c206d07b2700b"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ce119fc4ce0d64124d37f66a6f2a584fddc3c5001755f8a49f1ca0a177ef9796"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a17d8fac6df9835d8e2b4c5523666e7051d0897a93756518a1fe101c7f47f2f0"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ebc11c54c6ecdd07bb4efbfa1554538982f5432dfb8456958b6d46b9f834bb7"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e6965346fc1491a566e019a4a1d3dfc081ce7ac1a736536367ca305da6472a8"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:220574e78ad986aea8e81ac68821e47ea9202b7e44f251b7ed8c66d9ae3f4278"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-win32.whl", hash = "sha256:b75b00083e7fe6621ce13cfce9d4469c4774e55e8e9d38c305b37f13cf1e874c"}, + {file = "SQLAlchemy-2.0.34-cp39-cp39-win_amd64.whl", hash = "sha256:c29d03e0adf3cc1a8c3ec62d176824972ae29b67a66cbb18daff3062acc6faa8"}, + {file = "SQLAlchemy-2.0.34-py3-none-any.whl", hash = "sha256:7286c353ee6475613d8beff83167374006c6b3e3f0e6491bfe8ca610eb1dec0f"}, + {file = "sqlalchemy-2.0.34.tar.gz", hash = "sha256:10d8f36990dd929690666679b0f42235c159a7051534adb135728ee52828dd22"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "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\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "strict-rfc3339" +version = "0.7" +description = "Strict, simple, lightweight RFC3339 functions" +optional = false +python-versions = "*" +files = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "unidecode" +version = "1.3.8" +description = "ASCII transliterations of Unicode text" +optional = false +python-versions = ">=3.5" +files = [ + {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, + {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "werkzeug" +version = "2.3.8" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-2.3.8-py3-none-any.whl", hash = "sha256:bba1f19f8ec89d4d607a3bd62f1904bd2e609472d93cd85e9d4e178f472c3748"}, + {file = "werkzeug-2.3.8.tar.gz", hash = "sha256:554b257c74bbeb7a0d254160a4f8ffe185243f52a52035060b761ca62d977f03"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + +[[package]] +name = "wtforms" +version = "3.1.2" +description = "Form validation and rendering for Python web development." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07"}, + {file = "wtforms-3.1.2.tar.gz", hash = "sha256:f8d76180d7239c94c6322f7990ae1216dae3659b7aa1cee94b6318bdffb474b9"}, +] + +[package.dependencies] +markupsafe = "*" + +[package.extras] +email = ["email-validator"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "d1de34ca15101cf6e6da14291b3b62d06c6e9af1797d04501bdaca11f8ceb091" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..484135b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "submit-ce" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[tool.poetry] +name = "submit-ce" +version = "0.1.0" +description = "arXiv Submit" +authors = ["Brian D. Caruso "] +readme = "README.md" +packages = [{include = "arxiv", from="src"}] + +[tool.poetry.dependencies] +python = "^3.10" + +#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", rev = "6db6e9fc", extras = []} +#arxiv-base = "~=0.16.6" + +arxiv-auth = {git = "https://github.com/arXiv/arxiv-auth.git", rev = "develop", subdirectory = "arxiv-auth"} +#arxiv-auth = "~=0.4.2rc1" + +backports-datetime-fromisoformat = "*" +flask-sqlalchemy = "*" +jsonschema = "==2.6.0" +mimesis = "==2.1.0" +mypy = "*" +mypy_extensions = "*" + +python-dateutil = "*" +pytz = "==2018.7" +pyyaml = ">=4.2b1" +retry = "*" +unidecode = "*" +urllib3 = ">=1.24.2" +werkzeug = "^2.0" +#uwsgi = "==2.0.17.1" +semver = "^3.0.2" + +[tool.poetry.group.dev.dependencies] +coverage = "*" +coveralls = "*" +docker = "*" +mimesis = "*" +openapi-spec-validator = "*" +pydocstyle = "==3.0.0" +pylint = "<2" +pytest = "*" +pytest-cov = "*" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/schemas/openapiv1.yaml b/schemas/openapiv1.yaml new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/__init__.py b/src/arxiv/submission/__init__.py new file mode 100644 index 0000000..fb8aa2b --- /dev/null +++ b/src/arxiv/submission/__init__.py @@ -0,0 +1,257 @@ +""" +Core event-centric data abstraction for the submission & moderation subsystem. + +This package provides an event-based API for mutating submissions. Instead of +representing submissions as objects and mutating them directly in web +controllers and other places, we represent a submission as a stream of commands +or events. This ensures that we have a precise and complete record of +activities concerning submissions, and provides an explicit and consistent +definition of operations that can be performed within the arXiv submission +system. + +Overview +======== + +Event types are defined in :mod:`.domain.event`. The base class for all events +is :class:`.domain.event.base.Event`. Each event type defines additional +required data, and have ``validate`` and ``project`` methods that implement its +logic. Events operate on :class:`.domain.submission.Submission` instances. + +.. code-block:: python + + from arxiv.submission import CreateSubmission, User, Submission + user = User(1345, 'foo@user.com') + creation = CreateSubmission(creator=user) + + +:mod:`.core` defines the persistence API for submission data. +:func:`.core.save` is used to commit new events. :func:`.core.load` retrieves +events for a submission and plays them forward to get the current state, +whereas :func:`.core.load_fast` retrieves the latest projected state of the +submission (faster, theoretically less reliable). + +.. code-block:: python + + from arxiv.submission import save, SetTitle + submission, events = save(creation, SetTitle(creator=user, title='Title!')) + + +Watch out for :class:`.exceptions.InvalidEvent` to catch validation-related +problems (e.g. bad data, submission in wrong state). Watch for +:class:`.SaveError` to catch problems with persisting events. + +Callbacks can be attached to event types in order to execute routines +automatically when specific events are committed, using +:func:`.domain.Event.bind`. + +.. code-block:: python + + from typing import Iterable + + @SetTitle.bind() + def flip_title(event: SetTitle, before: Submissionm, after: Submission, + creator: Agent) -> Iterable[SetTitle]: + yield SetTitle(creator=creator, title=f"(╯°□°)╯︵ ┻━┻ {event.title}") + + +.. note: + Callbacks should **only** be used for actions that are specific to the + domain/concerns of the service in which they are implemented. For processes + that apply to all submissions, including asynchronous processes, + see :mod:`agent`. + + +Finally, :mod:`.services.classic` provides integration with the classic +submission database. We use the classic database to store events (new table), +and also keep its legacy tables up to date so that other legacy components +continue to work as expected. + + +Using commands/events +===================== + +Command/event classes are defined in :mod:`arxiv.submission.domain.event`, and +are accessible from the root namespace of this package. Each event type defines +a transformation/operation on a single submission, and defines the data +required to perform that operation. Events are played forward, in order, to +derive the state of a submission. For more information about how event types +are defined, see :class:`arxiv.submission.domain.event.Event`. + +.. note:: + + One major difference between the event stream and the classic submission + database table is that in the former model, there is only one submission id + for all versions/mutations. In the legacy system, new rows are created in + the submission table for things like creating a replacement, adding a DOI, + or requesting a withdrawal. The :ref:`legacy-integration` handles the + interchange between these two models. + +Commands/events types are `PEP 557 data classes +`_. Each command/event inherits from +:class:`.Event`, and may add additional fields. See :class:`.Event` for more +information about common fields. + +To create a new command/event, initialize the class with the relevant +data, and commit it using :func:`.save`. For example: + +.. code-block:: python + + >>> from arxiv.submission import User, SetTitle, save + >>> user = User(123, "joe@bloggs.com") + >>> update = SetTitle(creator=user, title='A new theory of foo') + >>> submission = save(creation, submission_id=12345) + + +If the commands/events are for a submission that already exists, the latest +state of that submission will be obtained by playing forward past events. New +events will be validated and applied to the submission in the order that they +were passed to :func:`.save`. + +- If an event is invalid (e.g. the submission is not in an appropriate state + for the operation), an :class:`.InvalidEvent` exception will be raised. + Note that at this point nothing has been changed in the database; the + attempt is simply abandoned. +- The command/event is stored, as is the latest state of the + submission. Events and the resulting state of the submission are stored + atomically. +- If the notification service is configured, a message about the event is + propagated as a Kinesis event on the configured stream. See + :mod:`arxiv.submission.services.notification` for details. + +Special case: creation +---------------------- +Note that if the first event is a :class:`.CreateSubmission` the +submission ID need not be provided, as we won't know what it is yet. For +example: + +.. code-block:: python + + from arxiv.submission import User, CreateSubmission, SetTitle, save + + >>> user = User(123, "joe@bloggs.com") + >>> creation = CreateSubmission(creator=user) + >>> update = SetTitle(creator=user, title='A new theory of foo') + >>> submission, events = save(creation, update) + >>> submission.submission_id + 40032 + + +.. _versioning-overview: + +Versioning events +================= +Handling changes to this software in a way that does not break past data is a +non-trivial problem. In a traditional relational database arrangement we would +leverage a database migration tool to do things like apply ``ALTER`` statements +to tables when upgrading software versions. The premise of the event data +model, however, is that events are immutable -- we won't be going back to +modify past events whenever we make a change to the software. + +The strategy for version management around event data is implemented in +:mod:`arxiv.submission.domain.events.versioning`. When event data is stored, +it is tagged with the current version of this software. When +event data are loaded from the store in this software, prior to instantiating +the appropriate :class:`.Event` subclass, the data are mapped to the current +software version using any defined version mappings for that event type. +This happens on the fly, in :func:`.domain.event.event_factory`. + + +.. _legacy-integration: + +Integration with the legacy system +================================== +The :mod:`.classic` service module provides integration with the classic +database. See the documentation for that module for details. As we migrate +off of the classic database, we will swap in a new service module with the +same API. + +Until all legacy components that read from or write to the classic database are +replaced, we will not be able to move entirely away from the legacy submission +database. Particularly in the submission and moderation UIs, design has assumed +immediate consistency, which means a conventional read/write interaction with +the database. Hence the classic integration module assumes that we are reading +and writing events and submission state from/to the same database. + +As development proceeds, we will look for opportunities to decouple from the +classic database, and focus on more localized projections of submission events +that are specific to a service/application. For example, the moderation UI/API +need not maintain or have access to the complete representation of the +submission; instead, it may track the subset of events relevant to its +operation (e.g. pertaining to metadata, classification, proposals, holds, etc). + +""" +import os +import time +from typing import Any, Protocol + +from flask import Flask, Blueprint + +import logging + +from .core import save, load, load_fast, SaveError +from .domain.agent import Agent, User, System, Client +from .domain.event import Event, InvalidEvent +from .domain.submission import Submission, SubmissionMetadata, Author +from .services import classic, StreamPublisher, Compiler, PlainTextService,\ + Classifier, PreviewService + +logger = logging.getLogger(__name__) + + +def init_app(app: Flask) -> None: + """ + Configure a Flask app to use this package. + + Initializes and waits for :class:`.StreamPublisher` and :mod:`.classic` + to be available. + """ + # Initialize services. + #logger.debug('Initialize StreamPublisher: %s', app.config['KINESIS_ENDPOINT']) + StreamPublisher.init_app(app) + #logger.debug('Initialize Preview Service: %s', app.config['PREVIEW_ENDPOINT']) + # PreviewService.init_app(app) + #logger.debug('Initialize Classic Database: %s', app.config['CLASSIC_DATABASE_URI']) + classic.init_app(app) + logger.debug('Done initializing classic DB') + + template_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), + 'templates') + app.register_blueprint( + Blueprint('submission-core', __name__, template_folder=template_folder) + ) + + logger.debug('Core: Wait for initializing services to spin up') + if app.config['WAIT_FOR_SERVICES']: + time.sleep(app.config['WAIT_ON_STARTUP']) + with app.app_context(): + stream_publisher = StreamPublisher.current_session() + stream_publisher.initialize() + wait_for(stream_publisher) + # Protocol doesn't work for modules. Either need a union type for + # wait_for, or use something other than a protocol for IAwaitable... + wait_for(classic) # type: ignore + logger.info('All upstream services are available; ready to start') + + +class IAwaitable(Protocol): + """An object that provides an ``is_available`` predicate.""" + + def is_available(self, **kwargs: Any) -> bool: + """Check whether an object (e.g. a service) is available.""" + ... + + +def wait_for(service: IAwaitable, delay: int = 2, **extra: Any) -> None: + """Wait for a service to become available.""" + if hasattr(service, '__name__'): + service_name = service.__name__ # type: ignore + elif hasattr(service, '__class__'): + service_name = service.__class__.__name__ + else: + service_name = str(service) + + logger.info('await %s', service_name) + while not service.is_available(**extra): + logger.info('service %s is not available; try again', service_name) + time.sleep(delay) + logger.info('service %s is available!', service_name) diff --git a/src/arxiv/submission/auth.py b/src/arxiv/submission/auth.py new file mode 100644 index 0000000..82eecaa --- /dev/null +++ b/src/arxiv/submission/auth.py @@ -0,0 +1,46 @@ + +from typing import List +import uuid +from datetime import datetime, timedelta + +from arxiv_auth import domain +from arxiv_auth.auth import tokens, scopes +from pytz import UTC + +from arxiv.base.globals import get_application_config + +from src.arxiv.submission import Agent, User + + +def get_system_token(name: str, agent: Agent, scopes: List[str]) -> str: + start = datetime.now(tz=UTC) + end = start + timedelta(seconds=36000) + if isinstance(agent, User): + user = domain.User( + username=agent.username, + email=agent.email, + user_id=agent.identifier, + name=agent.name, + verified=True + ) + else: + user = None + session = domain.Session( + session_id=str(uuid.uuid4()), + start_time=datetime.now(), end_time=end, + user=user, + client=domain.Client( + owner_id='system', + client_id=name, + name=name + ), + authorizations=domain.Authorizations(scopes=scopes) + ) + secret = get_application_config()['JWT_SECRET'] + return str(tokens.encode(session, secret)) + + +def get_compiler_scopes(resource: str) -> List[str]: + """Get minimal auth scopes necessary for compilation integration.""" + return [scopes.READ_COMPILE.for_resource(resource), + scopes.CREATE_COMPILE.for_resource(resource)] diff --git a/src/arxiv/submission/config.py b/src/arxiv/submission/config.py new file mode 100644 index 0000000..c195a0f --- /dev/null +++ b/src/arxiv/submission/config.py @@ -0,0 +1,299 @@ +"""Submission core configuration parameters.""" + +from os import environ +import warnings + +NAMESPACE = environ.get('NAMESPACE') +"""Namespace in which this service is deployed; to qualify keys for secrets.""" + +LOGLEVEL = int(environ.get('LOGLEVEL', '20')) +""" +Logging verbosity. + +See `https://docs.python.org/3/library/logging.html#levels`_. +""" + +JWT_SECRET = environ.get('JWT_SECRET') +"""Secret key for signing + verifying authentication JWTs.""" + +if not JWT_SECRET: + warnings.warn('JWT_SECRET is not set; authn/z may not work correctly!') + +CORE_VERSION = "0.0.0" + +MAX_SAVE_RETRIES = 25 +"""Number of times to retry storing/emiting a submission event.""" + +DEFAULT_SAVE_RETRY_DELAY = 30 +"""Delay between retry attempts when storing/emiting a submission event.""" + +WAIT_FOR_SERVICES = bool(int(environ.get('WAIT_FOR_SERVICES', '0'))) +"""Disable/enable waiting for upstream services to be available on startup.""" +if not WAIT_FOR_SERVICES: + warnings.warn('Awaiting upstream services is disabled; this should' + ' probably be enabled in production.') + +WAIT_ON_STARTUP = int(environ.get('WAIT_ON_STARTUP', '0')) +"""Number of seconds to wait before checking upstream services on startup.""" + +ENABLE_CALLBACKS = bool(int(environ.get('ENABLE_CALLBACKS', '1'))) +"""Enable/disable the :func:`Event.bind` feature.""" + + +# --- DATABASE CONFIGURATION --- + +CLASSIC_DATABASE_URI = environ.get('CLASSIC_DATABASE_URI', 'sqlite:///') +"""Full database URI for the classic system.""" + +SQLALCHEMY_DATABASE_URI = CLASSIC_DATABASE_URI +"""Full database URI for the classic system.""" + +SQLALCHEMY_TRACK_MODIFICATIONS = False +"""Track modifications feature should always be disabled.""" + +# --- AWS CONFIGURATION --- + +AWS_ACCESS_KEY_ID = environ.get('AWS_ACCESS_KEY_ID', 'nope') +""" +Access key for requests to AWS services. + +If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. +""" + +AWS_SECRET_ACCESS_KEY = environ.get('AWS_SECRET_ACCESS_KEY', 'nope') +""" +Secret auth key for requests to AWS services. + +If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. +""" + +AWS_REGION = environ.get('AWS_REGION', 'us-east-1') +"""Default region for calling AWS services.""" + + +# --- KINESIS CONFIGURATION --- + +KINESIS_STREAM = environ.get("KINESIS_STREAM", "SubmissionEvents") +"""Name of the stream on which to produce and consume events.""" + +KINESIS_SHARD_ID = environ.get("KINESIS_SHARD_ID", "0") +"""Shard ID for stream producer.""" + +KINESIS_ENDPOINT = environ.get("KINESIS_ENDPOINT", None) +""" +Alternate endpoint for connecting to Kinesis. + +If ``None``, uses the boto3 defaults for the :const:`AWS_REGION`. This is here +mainly to support development with localstack or other mocking frameworks. +""" + +KINESIS_VERIFY = bool(int(environ.get("KINESIS_VERIFY", "1"))) +""" +Enable/disable TLS certificate verification when connecting to Kinesis. + +This is here support development with localstack or other mocking frameworks. +""" + +if not KINESIS_VERIFY: + warnings.warn('Certificate verification for Kinesis is disabled; this' + ' should not be disabled in production.') + +# --- UPSTREAM SERVICE INTEGRATIONS --- +# +# See https://kubernetes.io/docs/concepts/services-networking/service/#environment-variables +# for details on service DNS and environment variables in k8s. + +# Integration with the file manager service. +FILEMANAGER_HOST = environ.get('FILEMANAGER_SERVICE_HOST', 'arxiv.org') +"""Hostname or addreess of the filemanager service.""" + +FILEMANAGER_PORT = environ.get('FILEMANAGER_SERVICE_PORT', '443') +"""Port for the filemanager service.""" + +FILEMANAGER_PROTO = environ.get(f'FILEMANAGER_PORT_{FILEMANAGER_PORT}_PROTO', + 'https') +"""Protocol for the filemanager service.""" + +FILEMANAGER_PATH = environ.get('FILEMANAGER_PATH', '').lstrip('/') +"""Path at which the filemanager service is deployed.""" + +FILEMANAGER_ENDPOINT = environ.get( + 'FILEMANAGER_ENDPOINT', + '%s://%s:%s/%s' % (FILEMANAGER_PROTO, FILEMANAGER_HOST, + FILEMANAGER_PORT, FILEMANAGER_PATH) +) +""" +Full URL to the root filemanager service API endpoint. + +If not explicitly provided, this is composed from :const:`FILEMANAGER_HOST`, +:const:`FILEMANAGER_PORT`, :const:`FILEMANAGER_PROTO`, and +:const:`FILEMANAGER_PATH`. +""" + +FILEMANAGER_VERIFY = bool(int(environ.get('FILEMANAGER_VERIFY', '1'))) +"""Enable/disable SSL certificate verification for filemanager service.""" + +if FILEMANAGER_PROTO == 'https' and not FILEMANAGER_VERIFY: + warnings.warn('Certificate verification for filemanager is disabled; this' + ' should not be disabled in production.') + +# Integration with the compiler service. +COMPILER_HOST = environ.get('COMPILER_SERVICE_HOST', 'arxiv.org') +"""Hostname or addreess of the compiler service.""" + +COMPILER_PORT = environ.get('COMPILER_SERVICE_PORT', '443') +"""Port for the compiler service.""" + +COMPILER_PROTO = environ.get(f'COMPILER_PORT_{COMPILER_PORT}_PROTO', 'https') +"""Protocol for the compiler service.""" + +COMPILER_PATH = environ.get('COMPILER_PATH', '') +"""Path at which the compiler service is deployed.""" + +COMPILER_ENDPOINT = environ.get( + 'COMPILER_ENDPOINT', + '%s://%s:%s/%s' % (COMPILER_PROTO, COMPILER_HOST, COMPILER_PORT, + COMPILER_PATH) +) +""" +Full URL to the root compiler service API endpoint. + +If not explicitly provided, this is composed from :const:`COMPILER_HOST`, +:const:`COMPILER_PORT`, :const:`COMPILER_PROTO`, and :const:`COMPILER_PATH`. +""" + +COMPILER_VERIFY = bool(int(environ.get('COMPILER_VERIFY', '1'))) +"""Enable/disable SSL certificate verification for compiler service.""" + +if COMPILER_PROTO == 'https' and not COMPILER_VERIFY: + warnings.warn('Certificate verification for compiler is disabled; this' + ' should not be disabled in production.') + +# Integration with the classifier service. +CLASSIFIER_HOST = environ.get('CLASSIFIER_SERVICE_HOST', 'localhost') +"""Hostname or addreess of the classifier service.""" + +CLASSIFIER_PORT = environ.get('CLASSIFIER_SERVICE_PORT', '8000') +"""Port for the classifier service.""" + +CLASSIFIER_PROTO = environ.get(f'CLASSIFIER_PORT_{CLASSIFIER_PORT}_PROTO', + 'http') +"""Protocol for the classifier service.""" + +CLASSIFIER_PATH = environ.get('CLASSIFIER_PATH', '/classifier/') +"""Path at which the classifier service is deployed.""" + +CLASSIFIER_ENDPOINT = environ.get( + 'CLASSIFIER_ENDPOINT', + '%s://%s:%s/%s' % (CLASSIFIER_PROTO, CLASSIFIER_HOST, CLASSIFIER_PORT, + CLASSIFIER_PATH) +) +""" +Full URL to the root classifier service API endpoint. + +If not explicitly provided, this is composed from :const:`CLASSIFIER_HOST`, +:const:`CLASSIFIER_PORT`, :const:`CLASSIFIER_PROTO`, and +:const:`CLASSIFIER_PATH`. +""" + +CLASSIFIER_VERIFY = bool(int(environ.get('CLASSIFIER_VERIFY', '0'))) +"""Enable/disable SSL certificate verification for classifier service.""" + +if CLASSIFIER_PROTO == 'https' and not CLASSIFIER_VERIFY: + warnings.warn('Certificate verification for classifier is disabled; this' + ' should not be disabled in production.') + +# Integration with plaintext extraction service. +PLAINTEXT_HOST = environ.get('PLAINTEXT_SERVICE_HOST', 'arxiv.org') +"""Hostname or addreess of the plaintext extraction service.""" + +PLAINTEXT_PORT = environ.get('PLAINTEXT_SERVICE_PORT', '443') +"""Port for the plaintext extraction service.""" + +PLAINTEXT_PROTO = environ.get(f'PLAINTEXT_PORT_{PLAINTEXT_PORT}_PROTO', + 'https') +"""Protocol for the plaintext extraction service.""" + +PLAINTEXT_PATH = environ.get('PLAINTEXT_PATH', '') +"""Path at which the plaintext extraction service is deployed.""" + +PLAINTEXT_ENDPOINT = environ.get( + 'PLAINTEXT_ENDPOINT', + '%s://%s:%s/%s' % (PLAINTEXT_PROTO, PLAINTEXT_HOST, PLAINTEXT_PORT, + PLAINTEXT_PATH) +) +""" +Full URL to the root plaintext extraction service API endpoint. + +If not explicitly provided, this is composed from :const:`PLAINTEXT_HOST`, +:const:`PLAINTEXT_PORT`, :const:`PLAINTEXT_PROTO`, and :const:`PLAINTEXT_PATH`. +""" + +PLAINTEXT_VERIFY = bool(int(environ.get('PLAINTEXT_VERIFY', '1'))) +"""Enable/disable certificate verification for plaintext extraction service.""" + +if PLAINTEXT_PROTO == 'https' and not PLAINTEXT_VERIFY: + warnings.warn('Certificate verification for plaintext extraction service' + ' is disabled; this should not be disabled in production.') + +# Email notification configuration. +EMAIL_ENABLED = bool(int(environ.get('EMAIL_ENABLED', '1'))) +"""Enable/disable sending e-mail. Default is enabled (True).""" + +DEFAULT_SENDER = environ.get('DEFAULT_SENDER', 'noreply@arxiv.org') +"""Default sender address for e-mail.""" + +SUPPORT_EMAIL = environ.get('SUPPORT_EMAIL', "help@arxiv.org") +"""E-mail address for user support.""" + +SMTP_HOSTNAME = environ.get('SMTP_HOSTNAME', 'localhost') +"""Hostname for the SMTP server.""" + +SMTP_USERNAME = environ.get('SMTP_USERNAME', 'foouser') +"""Username for the SMTP server.""" + +SMTP_PASSWORD = environ.get('SMTP_PASSWORD', 'foopass') +"""Password for the SMTP server.""" + +SMTP_PORT = int(environ.get('SMTP_PORT', '0')) +"""SMTP service port.""" + +SMTP_LOCAL_HOSTNAME = environ.get('SMTP_LOCAL_HOSTNAME', None) +"""Local host name to include in SMTP request.""" + +SMTP_SSL = bool(int(environ.get('SMTP_SSL', '0'))) +"""Enable/disable SSL for SMTP. Default is disabled.""" + +if not SMTP_SSL: + warnings.warn('Certificate verification for SMTP is disabled; this' + ' should not be disabled in production.') + + +# --- URL GENERATION --- + +EXTERNAL_URL_SCHEME = environ.get('EXTERNAL_URL_SCHEME', 'https') +"""Scheme to use for external URLs.""" + +if EXTERNAL_URL_SCHEME != 'https': + warnings.warn('External URLs will not use HTTPS proto') + +BASE_SERVER = environ.get('BASE_SERVER', 'arxiv.org') +"""Base arXiv server.""" + +SERVER_NAME = environ.get('SERVER_NAME', "submit.arxiv.org") +"""The name of this server.""" + +URLS = [ + ("submission", "/", SERVER_NAME), + ("confirmation", "//confirmation", SERVER_NAME) +] +""" +URLs for external services, for use with :func:`flask.url_for`. + +This subset of URLs is common only within submit, for now - maybe move to base +if these pages seem relevant to other services. + +For details, see :mod:`arxiv.base.urls`. +""" + +AUTH_UPDATED_SESSION_REF = True \ No newline at end of file diff --git a/src/arxiv/submission/core.py b/src/arxiv/submission/core.py new file mode 100644 index 0000000..2d09933 --- /dev/null +++ b/src/arxiv/submission/core.py @@ -0,0 +1,201 @@ +"""Core persistence methods for submissions and submission events.""" + +from typing import Callable, List, Dict, Mapping, Tuple, Iterable, Optional +from functools import wraps +from collections import defaultdict +from datetime import datetime +from pytz import UTC + +from flask import Flask + +import logging + +from .domain.submission import Submission, SubmissionMetadata, Author +from .domain.agent import Agent, User, System, Client +from .domain.event import Event, CreateSubmission +from .services import classic, StreamPublisher +from .exceptions import InvalidEvent, NoSuchSubmission, SaveError, NothingToDo + + +logger = logging.getLogger(__name__) + + +def load(submission_id: int) -> Tuple[Submission, List[Event]]: + """ + Load a submission and its history. + + This loads all events for the submission, and generates the most + up-to-date representation based on those events. + + Parameters + ---------- + submission_id : str + Submission identifier. + + Returns + ------- + :class:`.domain.submission.Submission` + The current state of the submission. + list + Items are :class:`.Event` instances, in order of their occurrence. + + Raises + ------ + :class:`arxiv.submission.exceptions.NoSuchSubmission` + Raised when a submission with the passed ID cannot be found. + + """ + try: + with classic.transaction(): + return classic.get_submission(submission_id) + except classic.NoSuchSubmission as e: + raise NoSuchSubmission(f'No submission with id {submission_id}') from e + + +def load_submissions_for_user(user_id: int) -> List[Submission]: + """ + Load active :class:`.domain.submission.Submission` for a specific user. + + Parameters + ---------- + user_id : int + Unique identifier for the user. + + Returns + ------- + list + Items are :class:`.domain.submission.Submission` instances. + + """ + with classic.transaction(): + return classic.get_user_submissions_fast(user_id) + + +def load_fast(submission_id: int) -> Submission: + """ + Load a :class:`.domain.submission.Submission` from its projected state. + + This does not load and apply past events. The most recent stored submission + state is loaded directly from the database. + + Parameters + ---------- + submission_id : str + Submission identifier. + + Returns + ------- + :class:`.domain.submission.Submission` + The current state of the submission. + + """ + try: + with classic.transaction(): + return classic.get_submission_fast(submission_id) + except classic.NoSuchSubmission as e: + raise NoSuchSubmission(f'No submission with id {submission_id}') from e + + +def save(*events: Event, submission_id: Optional[int] = None) \ + -> Tuple[Submission, List[Event]]: + """ + Commit a set of new :class:`.Event` instances for a submission. + + This will persist the events to the database, along with the final + state of the submission, and generate external notification(s) on the + appropriate channels. + + Parameters + ---------- + events : :class:`.Event` + Events to apply and persist. + submission_id : int + The unique ID for the submission, if available. If not provided, it is + expected that ``events`` includes a :class:`.CreateSubmission`. + + Returns + ------- + :class:`arxiv.submission.domain.submission.Submission` + The state of the submission after all events (including rule-derived + events) have been applied. Updated with the submission ID, if a + :class:`.CreateSubmission` was included. + list + A list of :class:`.Event` instances applied to the submission. Note + that this list may contain more events than were passed, if event + rules were triggered. + + Raises + ------ + :class:`arxiv.submission.exceptions.NoSuchSubmission` + Raised if ``submission_id`` is not provided and the first event is not + a :class:`.CreateSubmission`, or ``submission_id`` is provided but + no such submission exists. + :class:`.InvalidEvent` + If an invalid event is encountered, the entire operation is aborted + and this exception is raised. + :class:`.SaveError` + There was a problem persisting the events and/or submission state + to the database. + + """ + if len(events) == 0: + raise NothingToDo('Must pass at least one event') + events_list = list(events) # Coerce to list so that we can index. + prior: List[Event] = [] + before: Optional[Submission] = None + + # We need ACIDity surrounding the the validation and persistence of new + # events. + with classic.transaction(): + # Get the current state of the submission from past events. Normally we + # would not want to load all past events, but legacy components may be + # active, and the legacy projected state does not capture all of the + # detail in the event model. + if submission_id is not None: + # This will create a shared lock on the submission rows while we + # are working with them. + before, prior = classic.get_submission(submission_id, + for_update=True) + + # Either we need a submission ID, or the first event must be a + # creation. + elif events_list[0].submission_id is None \ + and not isinstance(events_list[0], CreateSubmission): + raise NoSuchSubmission('Unable to determine submission') + + committed: List[Event] = [] + for event in events_list: + # Fill in submission IDs, if they are missing. + if event.submission_id is None and submission_id is not None: + event.submission_id = submission_id + + # The created timestamp should be roughly when the event was + # committed. Since the event projection may refer to its own ID + # (which is based) on the creation time, this must be set before + # the event is applied. + event.created = datetime.now(UTC) + # Mutation happens here; raises InvalidEvent. + logger.debug('Apply event %s: %s', event.event_id, event.NAME) + after = event.apply(before) + committed.append(event) + if not event.committed: + after, consequent_events = event.commit(_store_event) + committed += consequent_events + + before = after # Prepare for the next event. + + all_ = sorted(set(prior) | set(committed), key=lambda e: e.created) + return after, list(all_) + + +def _store_event(event: Event, before: Optional[Submission], + after: Submission) -> Tuple[Event, Submission]: + return classic.store_event(event, before, after, StreamPublisher.put) + + +def init_app(app: Flask) -> None: + """Set default configuration parameters for an application instance.""" + classic.init_app(app) + StreamPublisher.init_app(app) + app.config.setdefault('ENABLE_CALLBACKS', 0) + app.config.setdefault('ENABLE_ASYNC', 0) diff --git a/src/arxiv/submission/domain/__init__.py b/src/arxiv/submission/domain/__init__.py new file mode 100644 index 0000000..8ef74b5 --- /dev/null +++ b/src/arxiv/submission/domain/__init__.py @@ -0,0 +1,12 @@ +"""Core data structures for the submission and moderation system.""" + +from .agent import User, System, Client, Agent, agent_factory +from .annotation import Comment +from .event import event_factory, Event +from .meta import Category, License, Classification +from .preview import Preview +from .proposal import Proposal +from .submission import Submission, SubmissionMetadata, Author, Hold, \ + WithdrawalRequest, UserRequest, CrossListClassificationRequest, \ + Compilation, SubmissionContent + diff --git a/src/arxiv/submission/domain/agent.py b/src/arxiv/submission/domain/agent.py new file mode 100644 index 0000000..922429d --- /dev/null +++ b/src/arxiv/submission/domain/agent.py @@ -0,0 +1,142 @@ +"""Data structures for agents.""" + +import hashlib +from typing import Any, Optional, List, Union, Type, Dict + +from dataclasses import dataclass, field +from dataclasses import asdict + +from .meta import Classification + +__all__ = ('Agent', 'User', 'System', 'Client', 'agent_factory') + + +@dataclass +class Agent: + """ + Base class for agents in the submission system. + + An agent is an actor/system that generates/is responsible for events. + """ + + native_id: str + """Type-specific identifier for the agent. This might be an URI.""" + + hostname: Optional[str] = field(default=None) + """Hostname or IP address from which user requests are originating.""" + + name: str = field(default_factory=str) + username: str = field(default_factory=str) + email: str = field(default_factory=str) + endorsements: List[str] = field(default_factory=list) + + def __post_init__(self) -> None: + """Set derivative fields.""" + self.agent_type = self.__class__.get_agent_type() + self.agent_identifier = self.get_agent_identifier() + + @classmethod + def get_agent_type(cls) -> str: + """Get the name of the instance's class.""" + return cls.__name__ + + def get_agent_identifier(self) -> str: + """ + Get the unique identifier for this agent instance. + + Based on both the agent type and native ID. + """ + h = hashlib.new('sha1') + h.update(b'%s:%s' % (self.agent_type.encode('utf-8'), + str(self.native_id).encode('utf-8'))) + return h.hexdigest() + + def __eq__(self, other: Any) -> bool: + """Equality comparison for agents based on type and identifier.""" + if not isinstance(other, self.__class__): + return False + return self.agent_identifier == other.agent_identifier + + +@dataclass +class User(Agent): + """An (human) end user.""" + + forename: str = field(default_factory=str) + surname: str = field(default_factory=str) + suffix: str = field(default_factory=str) + identifier: Optional[str] = field(default=None) + affiliation: str = field(default_factory=str) + + agent_type: str = field(default_factory=str) + agent_identifier: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Set derivative fields.""" + self.name = self.get_name() + self.agent_type = self.get_agent_type() + + def get_name(self) -> str: + """Full name of the user.""" + return f"{self.forename} {self.surname} {self.suffix}" + + +# TODO: extend this to support arXiv-internal services. +@dataclass +class System(Agent): + """The submission application (this application).""" + + agent_type: str = field(default_factory=str) + agent_identifier: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Set derivative fields.""" + super(System, self).__post_init__() + self.username = self.native_id + self.name = self.native_id + self.hostname = self.native_id + self.agent_type = self.get_agent_type() + + +@dataclass +class Client(Agent): + """A non-human third party, usually an API client.""" + + # hostname: Optional[str] = field(default=None) + # """Hostname or IP address from which client requests are originating.""" + + agent_type: str = field(default_factory=str) + agent_identifier: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Set derivative fields.""" + self.agent_type = self.get_agent_type() + self.username = self.native_id + self.name = self.native_id + + +_agent_types: Dict[str, Type[Agent]] = { + User.get_agent_type(): User, + System.get_agent_type(): System, + Client.get_agent_type(): Client, +} + + +def agent_factory(**data: Union[Agent, dict]) -> Agent: + """Instantiate a subclass of :class:`.Agent`.""" + if isinstance(data, Agent): + return data + agent_type = str(data.pop('agent_type')) + native_id = data.pop('native_id') + if not agent_type or not native_id: + raise ValueError('No such agent: %s, %s' % (agent_type, native_id)) + if agent_type not in _agent_types: + raise ValueError(f'No such agent type: {agent_type}') + + # Mypy chokes on meta-stuff like this. One of the goals of this factory + # function is to not have to write code for each agent subclass. We can + # revisit this in the future. For now, this code is correct, it just isn't + # easy to type-check. + klass = _agent_types[agent_type] + data = {k: v for k, v in data.items() if k in klass.__dataclass_fields__} # type: ignore + return klass(native_id, **data) # type: ignore diff --git a/src/arxiv/submission/domain/annotation.py b/src/arxiv/submission/domain/annotation.py new file mode 100644 index 0000000..369df99 --- /dev/null +++ b/src/arxiv/submission/domain/annotation.py @@ -0,0 +1,115 @@ +""" +Provides quality-assurance annotations for the submission & moderation system. +""" + +import hashlib +from datetime import datetime +from enum import Enum +from typing import Optional, Union, List, Dict, Type, Any + +from dataclasses import dataclass, asdict, field +from mypy_extensions import TypedDict + +from arxiv.taxonomy import Category + +from .agent import Agent, agent_factory +from .util import get_tzaware_utc_now + + +@dataclass +class Comment: + """A freeform textual annotation.""" + + event_id: str + creator: Agent + created: datetime + proxy: Optional[Agent] = field(default=None) + body: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Check our agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + + +ClassifierResult = TypedDict('ClassifierResult', + {'category': Category, 'probability': float}) + + +@dataclass +class Annotation: + event_id: str + creator: Agent + created: datetime + + def __post_init__(self) -> None: + """Check our agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + + +@dataclass +class ClassifierResults(Annotation): + """Represents suggested classifications from an auto-classifier.""" + + class Classifiers(Enum): + """Supported classifiers.""" + + CLASSIC = "classic" + + # event_id: str + # creator: Agent + # created: datetime + proxy: Optional[Agent] = field(default=None) + classifier: Classifiers = field(default=Classifiers.CLASSIC) + results: List[ClassifierResult] = field(default_factory=list) + annotation_type: str = field(default='ClassifierResults') + + def __post_init__(self) -> None: + """Check our enums.""" + super(ClassifierResults, self).__post_init__() + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + self.classifier = self.Classifiers(self.classifier) + + +@dataclass +class Feature(Annotation): + """Represents features drawn from the content of the submission.""" + + class Type(Enum): + """Supported features.""" + + CHARACTER_COUNT = "chars" + PAGE_COUNT = "pages" + STOPWORD_COUNT = "stops" + STOPWORD_PERCENT = "%stop" + WORD_COUNT = "words" + + # event_id: str + # created: datetime + # creator: Agent + feature_type: Type + proxy: Optional[Agent] = field(default=None) + feature_value: Union[int, float] = field(default=0) + annotation_type: str = field(default='Feature') + + def __post_init__(self) -> None: + """Check our enums.""" + super(Feature, self).__post_init__() + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + self.feature_type = self.Type(self.feature_type) + + +annotation_types: Dict[str, Type[Annotation]] = { + 'Feature': Feature, + 'ClassifierResults': ClassifierResults +} + + +def annotation_factory(**data: Any) -> Annotation: + an: Annotation = annotation_types[data.pop('annotation_type')](**data) + return an diff --git a/src/arxiv/submission/domain/compilation.py b/src/arxiv/submission/domain/compilation.py new file mode 100644 index 0000000..2954ea0 --- /dev/null +++ b/src/arxiv/submission/domain/compilation.py @@ -0,0 +1,166 @@ +"""Data structs related to compilation.""" + +import io +from datetime import datetime +from enum import Enum +from typing import Optional, NamedTuple, Dict + +from dataclasses import dataclass, field + + + +@dataclass +class Compilation: + """The state of a compilation attempt from the :mod:`.compiler` service.""" + + class Status(Enum): # type: ignore + """Acceptable compilation process statuses.""" + + SUCCEEDED = "completed" + IN_PROGRESS = "in_progress" + FAILED = "failed" + + class Format(Enum): # type: ignore + """Supported compilation output formats.""" + + PDF = "pdf" + DVI = "dvi" + PS = "ps" + + @property + def content_type(self) -> str: + """Get the MIME type for the compilation product.""" + _ctypes = { + type(self).PDF: 'application/pdf', + type(self).DVI: 'application/x-dvi', + type(self).PS: 'application/postscript' + } + return _ctypes[self] + + class SupportedCompiler(Enum): + """Compiler known to be supported by the compiler service.""" + + PDFLATEX = 'pdflatex' + + class Reason(Enum): + """Specific reasons for a (usually failure) outcome.""" + + AUTHORIZATION = "auth_error" + MISSING = "missing_source" + SOURCE_TYPE = "invalid_source_type" + CORRUPTED = "corrupted_source" + CANCELLED = "cancelled" + ERROR = "compilation_errors" + NETWORK = "network_error" + STORAGE = "storage" + DOCKER = 'docker' + NONE = None + + # Here are the actual slots/fields. + source_id: str + """This is the upload workspace identifier.""" + status: Status + """The status of the compilation.""" + checksum: str + """Checksum of the source package that we are compiling.""" + output_format: Format = field(default=Format.PDF) + """The requested output format.""" + reason: Reason = field(default=Reason.NONE) + """The specific reason for the :attr:`.status`.""" + description: Optional[str] = field(default=None) + """Additional detail about the :attr:`.status`.""" + size_bytes: int = field(default=0) + """The size of the compilation product in bytes.""" + product_checksum: Optional[str] = field(default=None) + """The checksum of the compilation product.""" + start_time: Optional[datetime] = field(default=None) + end_time: Optional[datetime] = field(default=None) + + def __post_init__(self) -> None: + """Check enums.""" + self.output_format = self.Format(self.output_format) + self.reason = self.Reason(self.reason) + if self.is_failed and self.is_succeeded: + raise ValueError('Cannot be failed, succeeded simultaneously') + if self.is_in_progress and self.is_finished: + raise ValueError('Cannot be finished, in progress simultaneously') + + @property + def identifier(self) -> str: + """Get the task identifier.""" + return self.get_identifier(self.source_id, self.checksum, + self.output_format) + + @staticmethod + def get_identifier(source_id: str, checksum: str, + output_format: Format = Format.PDF) -> str: + return f"{source_id}/{checksum}/{output_format.value}" + + @property + def content_type(self) -> str: + """Get the MIME type for the compilation product.""" + return str(self.output_format.content_type) + + @property + def is_succeeded(self) -> bool: + """Indicate whether or not the compilation ended successfully.""" + return bool(self.status == self.Status.SUCCEEDED) + + @property + def is_failed(self) -> bool: + """Indicate whether or not the compilation ended in failure.""" + return bool(self.status == self.Status.FAILED) + + @property + def is_finished(self) -> bool: + """Indicate whether or not the compilation ended.""" + return bool(self.is_succeeded or self.is_failed) + + @property + def is_in_progress(self) -> bool: + """Indicate whether or not the compilation is in progress.""" + return bool(not self.is_finished) + + +@dataclass +class CompilationProduct: + """Content of a compilation product itself.""" + + stream: io.BytesIO + """Readable buffer with the product content.""" + + content_type: str + """MIME-type of the stream.""" + + status: Optional[Compilation] = field(default=None) + """Status information about the product.""" + + checksum: Optional[str] = field(default=None) + """The B64-encoded MD5 hash of the compilation product.""" + + def __post_init__(self) -> None: + """Check status.""" + if self.status and isinstance(self.status, dict): + self.status = Compilation(**self.status) + + +@dataclass +class CompilationLog: + """Content of a compilation log.""" + + stream: io.BytesIO + """Readable buffer with the product content.""" + + status: Optional[Compilation] = field(default=None) + """Status information about the log.""" + + checksum: Optional[str] = field(default=None) + """The B64-encoded MD5 hash of the log.""" + + content_type: str = field(default='text/plain') + """MIME-type of the stream.""" + + def __post_init__(self) -> None: + """Check status.""" + if self.status and isinstance(self.status, dict): + self.status = Compilation(**self.status) diff --git a/src/arxiv/submission/domain/event/__init__.py b/src/arxiv/submission/domain/event/__init__.py new file mode 100644 index 0000000..94c5c8f --- /dev/null +++ b/src/arxiv/submission/domain/event/__init__.py @@ -0,0 +1,1354 @@ +""" +Data structures for submissions events. + +- Events have unique identifiers generated from their data (creation, agent, + submission). +- Events provide methods to update a submission based on the event data. +- Events provide validation methods for event data. + +Writing new events/commands +=========================== + +Events/commands are implemented as classes that inherit from :class:`.Event`. +It should: + +- Be a dataclass (i.e. be decorated with :func:`dataclasses.dataclass`). +- Define (using :func:`dataclasses.field`) associated data. +- Implement a validation method with the signature + ``validate(self, submission: Submission) -> None`` (see below). +- Implement a projection method with the signature + ``project(self, submission: Submission) -> Submission:`` that mutates + the passed :class:`.domain.submission.Submission` instance. + The projection *must not* generate side-effects, because it will be called + any time we are generating the state of a submission. If you need to + generate a side-effect, see :ref:`callbacks`\. +- Be fully documented. Be sure that the class docstring fully describes the + meaning of the event/command, and that both public and private methods have + at least a summary docstring. +- Have a corresponding :class:`unittest.TestCase` in + :mod:`arxiv.submission.domain.tests.test_events`. + +Adding validation to events +=========================== + +Each command/event class should implement an instance method +``validate(self, submission: Submission) -> None`` that raises +:class:`.InvalidEvent` exceptions if the data on the event instance is not +valid. + +For clarity, it's a good practice to individuate validation steps as separate +private instance methods, and call them from the public ``validate`` method. +This makes it easier to identify which validation criteria are being applied, +in what order, and what those criteria mean. + +See :class:`.SetPrimaryClassification` for an example. + +We could consider standalone validation functions for validation checks that +are performed on several event types (instead of just private instance +methods). + +.. _callbacks: + +Registering event callbacks +=========================== + +The base :class:`Event` provides support for callbacks that are executed when +an event instance is committed. To attach a callback to an event type, use the +:func:`Event.bind` decorator. For example: + +.. code-block:: python + + @SetTitle.bind() + def do_this_when_a_title_is_set(event, before, after, agent): + ... + return [] + + +Callbacks must have the signature ``(event: Event, before: Submission, +after: Submission, creator: Agent) -> Iterable[Event]``. ``event`` is the +event instance being committed that triggered the callback. ``before`` and +``after`` are the states of the submission before and after the event was +applied, respectively. ``agent`` is the agent responsible for any subsequent +events created by the callback, and should be used for that purpose. + +The callback should not concern itself with persistence; that is handled by +:func:`Event.commit`. Any mutations of submission should be made by returning +the appropriate command/event instances. + +The circumstances under which the callback is executed can be controlled by +passing a condition callable to the decorator. This should have the signature +``(event: Event, before: Submission, after: Submission, creator: Agent) -> +bool``; if it returns ``True``, the callback will be executed. For example: + +.. code-block:: python + + @SetTitle.bind(condition=lambda e, b, a, c: e.title == 'foo') + def do_this_when_a_title_is_set_to_foo(event, before, after, agent): + ... + return [] + + +When do things actually happen? +------------------------------- +Callbacks are triggered when the :func:`.commit` method is called, +usually by :func:`.core.save`. Normally, any event instances returned +by the callback are applied and committed right away, in order. + +Setting :mod:`.config.ENABLE_CALLBACKS=0` will disable callbacks +entirely. + +""" + +import copy +import hashlib +import re +from collections import defaultdict +from datetime import datetime +from functools import wraps +from typing import Optional, TypeVar, List, Tuple, Any, Dict, Union, Iterable,\ + Callable, ClassVar, Mapping +from urllib.parse import urlparse + +import bleach +from dataclasses import field, asdict +from pytz import UTC + +from arxiv import taxonomy +#from arxiv import identifier as arxiv_identifier +from arxiv.base import logging +from arxiv.base.globals import get_application_config + +from ...exceptions import InvalidEvent + +from ..agent import Agent, System, agent_factory +from ..annotation import Comment, Feature, ClassifierResults, \ + ClassifierResult +from ..preview import Preview +from ..submission import Submission, SubmissionMetadata, Author, \ + Classification, License, Delegation, \ + SubmissionContent, WithdrawalRequest, CrossListClassificationRequest +from ..util import get_tzaware_utc_now + +from . import validators +from .base import Event, event_factory, EventType +from .flag import AddMetadataFlag, AddUserFlag, AddContentFlag, RemoveFlag, \ + AddHold, RemoveHold +from .process import AddProcessStatus +from .proposal import AddProposal, RejectProposal, AcceptProposal +from .request import RequestCrossList, RequestWithdrawal, ApplyRequest, \ + RejectRequest, ApproveRequest, CancelRequest +from .util import dataclass + +logger = logging.getLogger(__name__) + + +# Events related to the creation of a new submission. +# +# These are largely the domain of the metadata API, and the submission UI. + + +@dataclass() +class CreateSubmission(Event): + """Creation of a new :class:`.domain.submission.Submission`.""" + + NAME = "create submission" + NAMED = "submission created" + + # This is the one event that deviates from the base Event class in not + # requiring/accepting a submission on which to operate. Python/mypy still + # has a little way to go in terms of supporting this kind of inheritance + # scenario. For reference, see: + # - https://github.com/python/typing/issues/269 + # - https://github.com/python/mypy/issues/5146 + # - https://github.com/python/typing/issues/241 + def validate(self, submission: None = None) -> None: # type: ignore + """Validate creation of a submission.""" + return + + def project(self, submission: None = None) -> Submission: # type: ignore + """Create a new :class:`.domain.submission.Submission`.""" + return Submission(creator=self.creator, created=self.created, + owner=self.creator, proxy=self.proxy, + client=self.client) + + +@dataclass(init=False) +class CreateSubmissionVersion(Event): + """ + Creates a new version of a submission. + + Takes the submission back to "working" state; the user or client may make + additional changes before finalizing the submission. + """ + + NAME = "create a new version" + NAMED = "new version created" + + def validate(self, submission: Submission) -> None: + """Only applies to announced submissions.""" + if not submission.is_announced: + raise InvalidEvent(self, "Must already be announced") + validators.no_active_requests(self, submission) + + def project(self, submission: Submission) -> Submission: + """Increment the version number, and reset several fields.""" + submission.version += 1 + submission.status = Submission.WORKING + # Return these to default. + submission.status = Submission.status + submission.source_content = Submission.source_content + submission.license = Submission.license + submission.submitter_is_author = Submission.submitter_is_author + submission.submitter_contact_verified = \ + Submission.submitter_contact_verified + submission.submitter_accepts_policy = \ + Submission.submitter_accepts_policy + submission.submitter_confirmed_preview = \ + Submission.submitter_confirmed_preview + return submission + + +@dataclass(init=False) +class Rollback(Event): + """Roll back to the most recent announced version, or delete.""" + + NAME = "roll back or delete" + NAMED = "rolled back or deleted" + + def validate(self, submission: Submission) -> None: + """Only applies to submissions in an unannounced state.""" + if submission.is_announced: + raise InvalidEvent(self, "Cannot already be announced") + elif submission.version > 1 and not submission.versions: + raise InvalidEvent(self, "No announced version to which to revert") + + def project(self, submission: Submission) -> Submission: + """Decrement the version number, and reset fields.""" + if submission.version == 1: + submission.status = Submission.DELETED + return submission + submission.version -= 1 + target = submission.versions[-1] + # Return these to last announced state. + submission.status = target.status + submission.source_content = target.source_content + submission.submitter_contact_verified = \ + target.submitter_contact_verified + submission.submitter_accepts_policy = \ + target.submitter_accepts_policy + submission.submitter_confirmed_preview = \ + target.submitter_confirmed_preview + submission.license = target.license + submission.metadata = copy.deepcopy(target.metadata) + return submission + + +@dataclass(init=False) +class ConfirmContactInformation(Event): + """Submitter has verified their contact information.""" + + NAME = "confirm contact information" + NAMED = "contact information confirmed" + + def validate(self, submission: Submission) -> None: + """Cannot apply to a finalized submission.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Update :attr:`.Submission.submitter_contact_verified`.""" + submission.submitter_contact_verified = True + return submission + + +@dataclass() +class ConfirmAuthorship(Event): + """The submitting user asserts whether they are an author of the paper.""" + + NAME = "confirm that submitter is an author" + NAMED = "submitter authorship status confirmed" + + submitter_is_author: bool = True + + def validate(self, submission: Submission) -> None: + """Cannot apply to a finalized submission.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Update the authorship flag on the submission.""" + submission.submitter_is_author = self.submitter_is_author + return submission + + +@dataclass(init=False) +class ConfirmPolicy(Event): + """The submitting user accepts the arXiv submission policy.""" + + NAME = "confirm policy acceptance" + NAMED = "policy acceptance confirmed" + + def validate(self, submission: Submission) -> None: + """Cannot apply to a finalized submission.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Set the policy flag on the submission.""" + submission.submitter_accepts_policy = True + return submission + + +@dataclass() +class SetPrimaryClassification(Event): + """Update the primary classification of a submission.""" + + NAME = "set primary classification" + NAMED = "primary classification set" + + category: Optional[taxonomy.Category] = None + + def validate(self, submission: Submission) -> None: + """Validate the primary classification category.""" + assert self.category is not None + validators.must_be_an_active_category(self, self.category, submission) + self._creator_must_be_endorsed(submission) + self._must_be_unannounced(submission) + validators.submission_is_not_finalized(self, submission) + validators.cannot_be_secondary(self, self.category, submission) + + def _must_be_unannounced(self, submission: Submission) -> None: + """Can only be set on the first version before publication.""" + if submission.arxiv_id is not None or submission.version > 1: + raise InvalidEvent(self, "Can only be set on the first version," + " before publication.") + + def _creator_must_be_endorsed(self, submission: Submission) -> None: + """Creator of this event must be endorsed for the category.""" + if isinstance(self.creator, System): + return + try: + archive = taxonomy.CATEGORIES[self.category]['in_archive'] + except KeyError: + archive = self.category + if self.category not in self.creator.endorsements \ + and f'{archive}.*' not in self.creator.endorsements \ + and '*.*' not in self.creator.endorsements: + raise InvalidEvent(self, f"Creator is not endorsed for" + f" {self.category}.") + + def project(self, submission: Submission) -> Submission: + """Set :attr:`.domain.Submission.primary_classification`.""" + assert self.category is not None + clsn = Classification(category=self.category) + submission.primary_classification = clsn + return submission + + def __post_init__(self) -> None: + """Ensure that we have an :class:`arxiv.taxonomy.Category`.""" + super(SetPrimaryClassification, self).__post_init__() + if self.category and not isinstance(self.category, taxonomy.Category): + self.category = taxonomy.Category(self.category) + + +@dataclass() +class AddSecondaryClassification(Event): + """Add a secondary :class:`.Classification` to a submission.""" + + NAME = "add cross-list classification" + NAMED = "cross-list classification added" + + category: Optional[taxonomy.Category] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Validate the secondary classification category to add.""" + assert self.category is not None + validators.must_be_an_active_category(self, self.category, submission) + validators.cannot_be_primary(self, self.category, submission) + validators.cannot_be_secondary(self, self.category, submission) + validators.max_secondaries(self, submission) + validators.no_redundant_general_category(self, self.category, submission) + validators.no_redundant_non_general_category(self, self.category, submission) + validators.cannot_be_genph(self, self.category, submission) + + def project(self, submission: Submission) -> Submission: + """Add a :class:`.Classification` as a secondary classification.""" + assert self.category is not None + classification = Classification(category=self.category) + submission.secondary_classification.append(classification) + return submission + + def __post_init__(self) -> None: + """Ensure that we have an :class:`arxiv.taxonomy.Category`.""" + super(AddSecondaryClassification, self).__post_init__() + if self.category and not isinstance(self.category, taxonomy.Category): + self.category = taxonomy.Category(self.category) + + +@dataclass() +class RemoveSecondaryClassification(Event): + """Remove secondary :class:`.Classification` from submission.""" + + NAME = "remove cross-list classification" + NAMED = "cross-list classification removed" + + category: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Validate the secondary classification category to remove.""" + assert self.category is not None + validators.must_be_an_active_category(self, self.category, submission) + self._must_already_be_present(submission) + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Remove from :attr:`.Submission.secondary_classification`.""" + assert self.category is not None + submission.secondary_classification = [ + classn for classn in submission.secondary_classification + if not classn.category == self.category + ] + return submission + + def _must_already_be_present(self, submission: Submission) -> None: + """One cannot remove a secondary that is not actually set.""" + if self.category not in submission.secondary_categories: + raise InvalidEvent(self, 'No such category on submission') + + +@dataclass() +class SetLicense(Event): + """The submitter has selected a license for their submission.""" + + NAME = "select distribution license" + NAMED = "distribution license selected" + + license_name: Optional[str] = field(default=None) + license_uri: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Validate the selected license.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Set :attr:`.domain.Submission.license`.""" + assert self.license_uri is not None + submission.license = License(name=self.license_name, + uri=self.license_uri) + return submission + + +@dataclass() +class SetTitle(Event): + """Update the title of a submission.""" + + NAME = "update title" + NAMED = "title updated" + + title: str = field(default='') + + MIN_LENGTH = 5 + MAX_LENGTH = 240 + ALLOWED_HTML = ["br", "sup", "sub", "hr", "em", "strong", "h"] + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetTitle, self).__post_init__() + self.title = self.cleanup(self.title) + + def validate(self, submission: Submission) -> None: + """Validate the title value.""" + validators.submission_is_not_finalized(self, submission) + self._does_not_contain_html_escapes(submission) + self._acceptable_length(submission) + validators.no_trailing_period(self, submission, self.title) + if self.title.isupper(): + raise InvalidEvent(self, "Title must not be all-caps") + self._check_for_html(submission) + + def project(self, submission: Submission) -> Submission: + """Update the title on a :class:`.domain.submission.Submission`.""" + submission.metadata.title = self.title + return submission + + def _does_not_contain_html_escapes(self, submission: Submission) -> None: + """The title must not contain HTML escapes.""" + if re.search(r"\&(?:[a-z]{3,4}|#x?[0-9a-f]{1,4})\;", self.title): + raise InvalidEvent(self, "Title may not contain HTML escapes") + + def _acceptable_length(self, submission: Submission) -> None: + """Verify that the title is an acceptable length.""" + N = len(self.title) + if N < self.MIN_LENGTH or N > self.MAX_LENGTH: + raise InvalidEvent(self, f"Title must be between {self.MIN_LENGTH}" + f" and {self.MAX_LENGTH} characters") + + # In classic, this is only an admin post-hoc check. + def _check_for_html(self, submission: Submission) -> None: + """Check for disallowed HTML.""" + N = len(self.title) + N_after = len(bleach.clean(self.title, tags=self.ALLOWED_HTML, + strip=True)) + if N > N_after: + raise InvalidEvent(self, "Title contains unacceptable HTML tags") + + @staticmethod + def cleanup(value: str) -> str: + """Perform some light tidying on the title.""" + value = re.sub(r"\s+", " ", value).strip() # Single spaces only. + return value + + +@dataclass() +class SetAbstract(Event): + """Update the abstract of a submission.""" + + NAME = "update abstract" + NAMED = "abstract updated" + + abstract: str = field(default='') + + MIN_LENGTH = 20 + MAX_LENGTH = 1920 + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetAbstract, self).__post_init__() + self.abstract = self.cleanup(self.abstract) + + def validate(self, submission: Submission) -> None: + """Validate the abstract value.""" + validators.submission_is_not_finalized(self, submission) + self._acceptable_length(submission) + + def project(self, submission: Submission) -> Submission: + """Update the abstract on a :class:`.domain.submission.Submission`.""" + submission.metadata.abstract = self.abstract + return submission + + def _acceptable_length(self, submission: Submission) -> None: + N = len(self.abstract) + if N < self.MIN_LENGTH or N > self.MAX_LENGTH: + raise InvalidEvent(self, + f"Abstract must be between {self.MIN_LENGTH}" + f" and {self.MAX_LENGTH} characters") + + @staticmethod + def cleanup(value: str) -> str: + """Perform some light tidying on the abstract.""" + value = value.strip() # Remove leading or trailing spaces + # Tidy paragraphs which should be indicated with "\n ". + value = re.sub(r"[ ]+\n", "\n", value) + value = re.sub(r"\n\s+", "\n ", value) + # Newline with no following space is removed, so treated as just a + # space in paragraph. + value = re.sub(r"(\S)\n(\S)", "\g<1> \g<2>", value) + # Tab->space, multiple spaces->space. + value = re.sub(r"\t", " ", value) + value = re.sub(r"(?", value) + # Remove lone period. + value = re.sub(r"\n\.\n", "\n", value) + value = re.sub(r"\n\.$", "", value) + return value + + +@dataclass() +class SetDOI(Event): + """Update the external DOI of a submission.""" + + NAME = "add a DOI" + NAMED = "DOI added" + + doi: str = field(default='') + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetDOI, self).__post_init__() + self.doi = self.cleanup(self.doi) + + def validate(self, submission: Submission) -> None: + """Validate the DOI value.""" + if submission.status == Submission.SUBMITTED \ + and not submission.is_announced: + raise InvalidEvent(self, 'Cannot edit a finalized submission') + if not self.doi: # Can be blank. + return + for value in re.split('[;,]', self.doi): + if not self._valid_doi(value.strip()): + raise InvalidEvent(self, f"Invalid DOI: {value}") + + def project(self, submission: Submission) -> Submission: + """Update the doi on a :class:`.domain.submission.Submission`.""" + submission.metadata.doi = self.doi + return submission + + def _valid_doi(self, value: str) -> bool: + if re.match(r"^10\.\d{4,5}\/\S+$", value): + return True + return False + + @staticmethod + def cleanup(value: str) -> str: + """Perform some light tidying on the title.""" + value = re.sub(r"\s+", " ", value).strip() # Single spaces only. + return value + + +@dataclass() +class SetMSCClassification(Event): + """Update the MSC classification codes of a submission.""" + + NAME = "update MSC classification" + NAMED = "MSC classification updated" + + msc_class: str = field(default='') + + MAX_LENGTH = 160 + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetMSCClassification, self).__post_init__() + self.msc_class = self.cleanup(self.msc_class) + + def validate(self, submission: Submission) -> None: + """Validate the MSC classification value.""" + validators.submission_is_not_finalized(self, submission) + if not self.msc_class: # Blank values are OK. + return + + def project(self, submission: Submission) -> Submission: + """Update the MSC classification on a :class:`.domain.submission.Submission`.""" + submission.metadata.msc_class = self.msc_class + return submission + + @staticmethod + def cleanup(value: str) -> str: + """Perform some light fixes on the MSC classification value.""" + value = re.sub(r"\s+", " ", value).strip() + value = re.sub(r"\s*\.[\s.]*$", "", value) + value = value.replace(";", ",") # No semicolons, should be comma. + value = re.sub(r"\s*,\s*", ", ", value) # Want: comma, space. + value = re.sub(r"^MSC([\s:\-]{0,4}(classification|class|number))?" + r"([\s:\-]{0,4}\(?2000\)?)?[\s:\-]*", + "", value, flags=re.I) + return value + + +@dataclass() +class SetACMClassification(Event): + """Update the ACM classification codes of a submission.""" + + NAME = "update ACM classification" + NAMED = "ACM classification updated" + + acm_class: str = field(default='') + """E.g. F.2.2; I.2.7""" + + MAX_LENGTH = 160 + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetACMClassification, self).__post_init__() + self.acm_class = self.cleanup(self.acm_class) + + def validate(self, submission: Submission) -> None: + """Validate the ACM classification value.""" + validators.submission_is_not_finalized(self, submission) + if not self.acm_class: # Blank values are OK. + return + self._valid_acm_class(submission) + + def project(self, submission: Submission) -> Submission: + """Update the ACM classification on a :class:`.domain.submission.Submission`.""" + submission.metadata.acm_class = self.acm_class + return submission + + def _valid_acm_class(self, submission: Submission) -> None: + """Check that the value is a valid ACM class.""" + ptn = r"^[A-K]\.[0-9m](\.(\d{1,2}|m)(\.[a-o])?)?$" + for acm_class in self.acm_class.split(';'): + if not re.match(ptn, acm_class.strip()): + raise InvalidEvent(self, f"Not a valid ACM class: {acm_class}") + + @staticmethod + def cleanup(value: str) -> str: + """Perform light cleanup.""" + value = re.sub(r"\s+", " ", value).strip() + value = re.sub(r"\s*\.[\s.]*$", "", value) + value = re.sub(r"^ACM-class:\s+", "", value, flags=re.I) + value = value.replace(",", ";") + _value = [] + for v in value.split(';'): + v = v.strip().upper().rstrip('.') + v = re.sub(r"^([A-K])(\d)", "\g<1>.\g<2>", v) + v = re.sub(r"M$", "m", v) + _value.append(v) + value = "; ".join(_value) + return value + + +@dataclass() +class SetJournalReference(Event): + """Update the journal reference of a submission.""" + + NAME = "add a journal reference" + NAMED = "journal reference added" + + journal_ref: str = field(default='') + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetJournalReference, self).__post_init__() + self.journal_ref = self.cleanup(self.journal_ref) + + def validate(self, submission: Submission) -> None: + """Validate the journal reference value.""" + if not self.journal_ref: # Blank values are OK. + return + self._no_disallowed_words(submission) + self._contains_valid_year(submission) + + def project(self, submission: Submission) -> Submission: + """Update the journal reference on a :class:`.domain.submission.Submission`.""" + submission.metadata.journal_ref = self.journal_ref + return submission + + def _no_disallowed_words(self, submission: Submission) -> None: + """Certain words are not permitted.""" + for word in ['submit', 'in press', 'appear', 'accept', 'to be publ']: + if word in self.journal_ref.lower(): + raise InvalidEvent(self, + f"The word '{word}' should appear in the" + f" comments, not the Journal ref") + + def _contains_valid_year(self, submission: Submission) -> None: + """Must contain a valid year.""" + if not re.search(r"(\A|\D)(19|20)\d\d(\D|\Z)", self.journal_ref): + raise InvalidEvent(self, "Journal reference must include a year") + + @staticmethod + def cleanup(value: str) -> str: + """Perform light cleanup.""" + value = value.replace('PHYSICAL REVIEW LETTERS', + 'Physical Review Letters') + value = value.replace('PHYSICAL REVIEW', 'Physical Review') + value = value.replace('OPTICS LETTERS', 'Optics Letters') + return value + + +@dataclass() +class SetReportNumber(Event): + """Update the report number of a submission.""" + + NAME = "update report number" + NAMED = "report number updated" + + report_num: str = field(default='') + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetReportNumber, self).__post_init__() + self.report_num = self.cleanup(self.report_num) + + def validate(self, submission: Submission) -> None: + """Validate the report number value.""" + if not self.report_num: # Blank values are OK. + return + if not re.search(r"\d\d", self.report_num): + raise InvalidEvent(self, "Report number must contain two" + " consecutive digits") + + def project(self, submission: Submission) -> Submission: + """Set report number on a :class:`.domain.submission.Submission`.""" + submission.metadata.report_num = self.report_num + return submission + + @staticmethod + def cleanup(value: str) -> str: + """Light cleanup on report number value.""" + value = re.sub(r"\s+", " ", value).strip() + value = re.sub(r"\s*\.[\s.]*$", "", value) + return value + + +@dataclass() +class SetComments(Event): + """Update the comments of a submission.""" + + NAME = "update comments" + NAMED = "comments updated" + + comments: str = field(default='') + + MAX_LENGTH = 400 + + def __post_init__(self) -> None: + """Perform some light cleanup on the provided value.""" + super(SetComments, self).__post_init__() + self.comments = self.cleanup(self.comments) + + def validate(self, submission: Submission) -> None: + """Validate the comments value.""" + validators.submission_is_not_finalized(self, submission) + if not self.comments: # Blank values are OK. + return + if len(self.comments) > self.MAX_LENGTH: + raise InvalidEvent(self, f"Comments must be no more than" + f" {self.MAX_LENGTH} characters long") + + def project(self, submission: Submission) -> Submission: + """Update the comments on a :class:`.domain.submission.Submission`.""" + submission.metadata.comments = self.comments + return submission + + @staticmethod + def cleanup(value: str) -> str: + """Light cleanup on comment value.""" + value = re.sub(r"\s+", " ", value).strip() + value = re.sub(r"\s*\.[\s.]*$", "", value) + return value + + +@dataclass() +class SetAuthors(Event): + """Update the authors on a :class:`.domain.submission.Submission`.""" + + NAME = "update authors" + NAMED = "authors updated" + + authors: List[Author] = field(default_factory=list) + authors_display: Optional[str] = field(default=None) + """The authors string may be provided.""" + + def __post_init__(self) -> None: + """Autogenerate and/or clean display names.""" + super(SetAuthors, self).__post_init__() + self.authors = [ + Author(**a) if isinstance(a, dict) else a # type: ignore + for a in self.authors + ] + if not self.authors_display: + self.authors_display = self._canonical_author_string() + self.authors_display = self.cleanup(self.authors_display) + + def validate(self, submission: Submission) -> None: + """May not apply to a finalized submission.""" + validators.submission_is_not_finalized(self, submission) + self._does_not_contain_et_al() + + def _canonical_author_string(self) -> str: + """Canonical representation of authors, using display names.""" + return ", ".join([au.display for au in self.authors + if au.display is not None]) + + @staticmethod + def cleanup(s: str) -> str: + """Perform some light tidying on the provided author string(s).""" + s = re.sub(r"\s+", " ", s) # Single spaces only. + s = re.sub(r",(\s*,)+", ",", s) # Remove double commas. + # Add spaces between word and opening parenthesis. + s = re.sub(r"(\w)\(", r"\g<1> (", s) + # Add spaces between closing parenthesis and word. + s = re.sub(r"\)(\w)", r") \g<1>", s) + # Change capitalized or uppercase `And` to `and`. + s = re.sub(r"\bA(?i:ND)\b", "and", s) + return s.strip() # Removing leading and trailing whitespace. + + def _does_not_contain_et_al(self) -> None: + """The authors display value should not contain `et al`.""" + if self.authors_display and \ + re.search(r"et al\.?($|\s*\()", self.authors_display): + raise InvalidEvent(self, "Authors should not contain et al.") + + def project(self, submission: Submission) -> Submission: + """Replace :attr:`.Submission.metadata.authors`.""" + assert self.authors_display is not None + submission.metadata.authors = self.authors + submission.metadata.authors_display = self.authors_display + return submission + + +@dataclass() +class SetUploadPackage(Event): + """Set the upload workspace for this submission.""" + + NAME = "set the upload package" + NAMED = "upload package set" + + identifier: str = field(default_factory=str) + checksum: str = field(default_factory=str) + uncompressed_size: int = field(default=0) + compressed_size: int = field(default=0) + source_format: SubmissionContent.Format = \ + field(default=SubmissionContent.Format.UNKNOWN) + + def __post_init__(self) -> None: + """Make sure that `source_format` is an enum instance.""" + super(SetUploadPackage, self).__post_init__() + if type(self.source_format) is str: + self.source_format = SubmissionContent.Format(self.source_format) + + def validate(self, submission: Submission) -> None: + """Validate data for :class:`.SetUploadPackage`.""" + validators.submission_is_not_finalized(self, submission) + + if not self.identifier: + raise InvalidEvent(self, 'Missing upload ID') + + def project(self, submission: Submission) -> Submission: + """Replace :class:`.SubmissionContent` metadata on the submission.""" + submission.source_content = SubmissionContent( + checksum=self.checksum, + identifier=self.identifier, + uncompressed_size=self.uncompressed_size, + compressed_size=self.compressed_size, + source_format=self.source_format, + ) + submission.submitter_confirmed_preview = False + return submission + + +@dataclass() +class UpdateUploadPackage(Event): + """Update the upload workspace on this submission.""" + + NAME = "update the upload package" + NAMED = "upload package updated" + + checksum: str = field(default_factory=str) + uncompressed_size: int = field(default=0) + compressed_size: int = field(default=0) + source_format: SubmissionContent.Format = \ + field(default=SubmissionContent.Format.UNKNOWN) + + def __post_init__(self) -> None: + """Make sure that `source_format` is an enum instance.""" + super(UpdateUploadPackage, self).__post_init__() + if type(self.source_format) is str: + self.source_format = SubmissionContent.Format(self.source_format) + + def validate(self, submission: Submission) -> None: + """Validate data for :class:`.SetUploadPackage`.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Replace :class:`.SubmissionContent` metadata on the submission.""" + assert submission.source_content is not None + assert self.source_format is not None + assert self.checksum is not None + assert self.uncompressed_size is not None + assert self.compressed_size is not None + submission.source_content.source_format = self.source_format + submission.source_content.checksum = self.checksum + submission.source_content.uncompressed_size = self.uncompressed_size + submission.source_content.compressed_size = self.compressed_size + submission.submitter_confirmed_preview = False + return submission + + +@dataclass() +class UnsetUploadPackage(Event): + """Unset the upload workspace for this submission.""" + + NAME = "unset the upload package" + NAMED = "upload package unset" + + def validate(self, submission: Submission) -> None: + """Validate data for :class:`.UnsetUploadPackage`.""" + validators.submission_is_not_finalized(self, submission) + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.source_content` to None.""" + submission.source_content = None + submission.submitter_confirmed_preview = False + return submission + + +@dataclass() +class ConfirmSourceProcessed(Event): + """ + Confirm that the submission source was successfully processed. + + For TeX and PS submissions, this will involve compilation using the AutoTeX + tree. For PDF-only submissions, this may simply involve checking that a + PDF exists. + + If this event has occurred, it indicates that a preview of the submission + content is available. + """ + + NAME = "confirm source has been processed" + NAMED = "confirmed that source has been processed" + + source_id: int = field(default=-1) + """Identifier of the source from which the preview was generated.""" + + source_checksum: str = field(default='') + """Checksum of the source from which the preview was generated.""" + + preview_checksum: str = field(default='') + """Checksum of the preview content itself.""" + + size_bytes: int = field(default=-1) + """Size (in bytes) of the preview content.""" + + added: Optional[datetime] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Make sure that a preview is actually provided.""" + # if self.source_id < 0: + # raise InvalidEvent(self, "Preview not provided") + # if not self.source_checksum: + # raise InvalidEvent(self, 'Missing source checksum') + # if not self.preview_checksum: + # raise InvalidEvent(self, 'Missing preview checksum') + # if not self.size_bytes: + # raise InvalidEvent(self, 'Missing preview size') + # if self.added is None: + # raise InvalidEvent(self, 'Missing added datetime') + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.is_source_processed`.""" + submission.is_source_processed = True + submission.preview = Preview(source_id=self.source_id, # type: ignore + source_checksum=self.source_checksum, + preview_checksum=self.preview_checksum, + size_bytes=self.size_bytes, + added=self.added) + return submission + + +@dataclass() +class UnConfirmSourceProcessed(Event): + """ + Unconfirm that the submission source was successfully processed. + + This can be used to mark a submission as unprocessed even though the + source content has not changed. For example, when reprocessing a + submission. + """ + + NAME = "unconfirm source has been processed" + NAMED = "unconfirmed that source has been processed" + + def validate(self, submission: Submission) -> None: + """Nothing to do.""" + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.is_source_processed`.""" + submission.is_source_processed = False + submission.preview = None + return submission + + +@dataclass() +class ConfirmPreview(Event): + """ + Confirm that the paper and abstract previews are acceptable. + + This event indicates that the submitter has viewed the content preview as + well as the metadata that will be displayed on the abstract page, and + affirms the acceptability of all content. + """ + + NAME = "approve submission preview" + NAMED = "submission preview approved" + + preview_checksum: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Validate data for :class:`.ConfirmPreview`.""" + validators.submission_is_not_finalized(self, submission) + if submission.preview is None: + raise InvalidEvent(self, "Preview not set on submission") + if self.preview_checksum != submission.preview.preview_checksum: + raise InvalidEvent( + self, + f"Checksum {self.preview_checksum} does not match current" + f" preview checksum: {submission.preview.preview_checksum}" + ) + + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.submitter_confirmed_preview`.""" + submission.submitter_confirmed_preview = True + return submission + + +@dataclass(init=False) +class FinalizeSubmission(Event): + """Send the submission to the queue for announcement.""" + + NAME = "finalize submission for announcement" + NAMED = "submission finalized" + + REQUIRED = [ + 'creator', 'primary_classification', 'submitter_contact_verified', + 'submitter_accepts_policy', 'license', 'source_content', 'metadata', + ] + REQUIRED_METADATA = ['title', 'abstract', 'authors_display'] + + def validate(self, submission: Submission) -> None: + """Ensure that all required data/steps are complete.""" + if submission.is_finalized: + raise InvalidEvent(self, "Submission already finalized") + if not submission.is_active: + raise InvalidEvent(self, "Submission must be active") + self._required_fields_are_complete(submission) + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.is_finalized`.""" + submission.status = Submission.SUBMITTED + submission.submitted = datetime.now(UTC) + return submission + + def _required_fields_are_complete(self, submission: Submission) -> None: + """Verify that all required fields are complete.""" + for key in self.REQUIRED: + if not getattr(submission, key): + raise InvalidEvent(self, f"Missing {key}") + for key in self.REQUIRED_METADATA: + if not getattr(submission.metadata, key): + raise InvalidEvent(self, f"Missing {key}") + + +@dataclass() +class UnFinalizeSubmission(Event): + """Withdraw the submission from the queue for announcement.""" + + NAME = "re-open submission for modification" + NAMED = "submission re-opened for modification" + + def validate(self, submission: Submission) -> None: + """Validate the unfinalize action.""" + self._must_be_finalized(submission) + if submission.is_announced: + raise InvalidEvent(self, "Cannot unfinalize an announced paper") + + def _must_be_finalized(self, submission: Submission) -> None: + """May only unfinalize a finalized submission.""" + if not submission.is_finalized: + raise InvalidEvent(self, "Submission is not finalized") + + def project(self, submission: Submission) -> Submission: + """Set :attr:`Submission.is_finalized`.""" + submission.status = Submission.WORKING + submission.submitted = None + return submission + + +@dataclass() +class Announce(Event): + """Announce the current version of the submission.""" + + NAME = "publish submission" + NAMED = "submission announced" + + arxiv_id: Optional[str] = None + + def validate(self, submission: Submission) -> None: + """Make sure that we have a valid arXiv ID.""" + # TODO: When we're using this to perform publish in NG, we will want to + # re-enable this step. + # + # if not submission.status == Submission.SUBMITTED: + # raise InvalidEvent(self, + # "Can't publish in state %s" % submission.status) + # if self.arxiv_id is None: + # raise InvalidEvent(self, "Must provide an arXiv ID.") + # try: + # arxiv_identifier.parse_arxiv_id(self.arxiv_id) + # except ValueError: + # raise InvalidEvent(self, "Not a valid arXiv ID.") + + def project(self, submission: Submission) -> Submission: + """Set the arXiv ID on the submission.""" + submission.arxiv_id = self.arxiv_id + submission.status = Submission.ANNOUNCED + submission.versions.append(copy.deepcopy(submission)) + return submission + + +# Moderation-related events. + + +# @dataclass() +# class CreateComment(Event): +# """Creation of a :class:`.Comment` on a :class:`.domain.submission.Submission`.""" +# +# read_scope = 'submission:moderate' +# write_scope = 'submission:moderate' +# +# body: str = field(default_factory=str) +# scope: str = 'private' +# +# def validate(self, submission: Submission) -> None: +# """The :attr:`.body` should be set.""" +# if not self.body: +# raise ValueError('Comment body not set') +# +# def project(self, submission: Submission) -> Submission: +# """Create a new :class:`.Comment` and attach it to the submission.""" +# submission.comments[self.event_id] = Comment( +# event_id=self.event_id, +# creator=self.creator, +# created=self.created, +# proxy=self.proxy, +# submission=submission, +# body=self.body +# ) +# return submission +# +# +# @dataclass() +# class DeleteComment(Event): +# """Deletion of a :class:`.Comment` on a :class:`.domain.submission.Submission`.""" +# +# read_scope = 'submission:moderate' +# write_scope = 'submission:moderate' +# +# comment_id: str = field(default_factory=str) +# +# def validate(self, submission: Submission) -> None: +# """The :attr:`.comment_id` must present on the submission.""" +# if self.comment_id is None: +# raise InvalidEvent(self, 'comment_id is required') +# if not hasattr(submission, 'comments') or not submission.comments: +# raise InvalidEvent(self, 'Cannot delete nonexistant comment') +# if self.comment_id not in submission.comments: +# raise InvalidEvent(self, 'Cannot delete nonexistant comment') +# +# def project(self, submission: Submission) -> Submission: +# """Remove the comment from the submission.""" +# del submission.comments[self.comment_id] +# return submission +# +# +# @dataclass() +# class AddDelegate(Event): +# """Owner delegates authority to another agent.""" +# +# delegate: Optional[Agent] = None +# +# def validate(self, submission: Submission) -> None: +# """The event creator must be the owner of the submission.""" +# if not self.creator == submission.owner: +# raise InvalidEvent(self, 'Event creator must be submission owner') +# +# def project(self, submission: Submission) -> Submission: +# """Add the delegate to the submission.""" +# delegation = Delegation( +# creator=self.creator, +# delegate=self.delegate, +# created=self.created +# ) +# submission.delegations[delegation.delegation_id] = delegation +# return submission +# +# +# @dataclass() +# class RemoveDelegate(Event): +# """Owner revokes authority from another agent.""" +# +# delegation_id: str = field(default_factory=str) +# +# def validate(self, submission: Submission) -> None: +# """The event creator must be the owner of the submission.""" +# if not self.creator == submission.owner: +# raise InvalidEvent(self, 'Event creator must be submission owner') +# +# def project(self, submission: Submission) -> Submission: +# """Remove the delegate from the submission.""" +# if self.delegation_id in submission.delegations: +# del submission.delegations[self.delegation_id] +# return submission + + +@dataclass() +class AddFeature(Event): + """Add feature metadata to a submission.""" + + NAME = "add feature metadata" + NAMED = "feature metadata added" + + feature_type: Feature.Type = \ + field(default=Feature.Type.WORD_COUNT) + feature_value: Union[float, int] = field(default=0) + + def validate(self, submission: Submission) -> None: + """Verify that the feature type is a known value.""" + if self.feature_type not in Feature.Type: + valid_types = ", ".join([ft.value for ft in Feature.Type]) + raise InvalidEvent(self, "Must be one of %s" % valid_types) + + def project(self, submission: Submission) -> Submission: + """Add the annotation to the submission.""" + assert self.created is not None + submission.annotations[self.event_id] = Feature( + event_id=self.event_id, + creator=self.creator, + created=self.created, + proxy=self.proxy, + feature_type=self.feature_type, + feature_value=self.feature_value + ) + return submission + + +@dataclass() +class AddClassifierResults(Event): + """Add the results of a classifier to a submission.""" + + NAME = "add classifer results" + NAMED = "classifier results added" + + classifier: ClassifierResults.Classifiers \ + = field(default=ClassifierResults.Classifiers.CLASSIC) + results: List[ClassifierResult] = field(default_factory=list) + + def validate(self, submission: Submission) -> None: + """Verify that the classifier is a known value.""" + if self.classifier not in ClassifierResults.Classifiers: + valid = ", ".join([c.value for c in ClassifierResults.Classifiers]) + raise InvalidEvent(self, "Must be one of %s" % valid) + + def project(self, submission: Submission) -> Submission: + """Add the annotation to the submission.""" + assert self.created is not None + submission.annotations[self.event_id] = ClassifierResults( + event_id=self.event_id, + creator=self.creator, + created=self.created, + proxy=self.proxy, + classifier=self.classifier, + results=self.results + ) + return submission + + +@dataclass() +class Reclassify(Event): + """Reclassify a submission.""" + + NAME = "reclassify submission" + NAMED = "submission reclassified" + + category: Optional[taxonomy.Category] = None + + def validate(self, submission: Submission) -> None: + """Validate the primary classification category.""" + assert isinstance(self.category, str) + validators.must_be_an_active_category(self, self.category, submission) + self._must_be_unannounced(submission) + validators.cannot_be_secondary(self, self.category, submission) + + def _must_be_unannounced(self, submission: Submission) -> None: + """Can only be set on the first version before publication.""" + if submission.arxiv_id is not None or submission.version > 1: + raise InvalidEvent(self, "Can only be set on the first version," + " before publication.") + + def project(self, submission: Submission) -> Submission: + """Set :attr:`.domain.Submission.primary_classification`.""" + clsn = Classification(category=self.category) + submission.primary_classification = clsn + return submission diff --git a/src/arxiv/submission/domain/event/base.py b/src/arxiv/submission/domain/event/base.py new file mode 100644 index 0000000..8cdb32a --- /dev/null +++ b/src/arxiv/submission/domain/event/base.py @@ -0,0 +1,353 @@ +"""Provides the base event class.""" + +import copy +import hashlib +from collections import defaultdict +from datetime import datetime +from functools import wraps +from typing import Optional, Callable, Tuple, Iterable, List, ClassVar, \ + Mapping, Type, Any, overload + +from dataclasses import field, asdict +from flask import current_app +from pytz import UTC + +from arxiv.base import logging +from arxiv.base.globals import get_application_config + +from ...exceptions import InvalidEvent +from ..agent import Agent, System, agent_factory +from ..submission import Submission +from ..util import get_tzaware_utc_now +from .util import dataclass +from .versioning import EventData, map_to_current_version + +logger = logging.getLogger(__name__) +logger.propagate = False + +Events = Iterable['Event'] +Condition = Callable[['Event', Optional[Submission], Submission], bool] +Callback = Callable[['Event', Optional[Submission], Submission], Events] +Decorator = Callable[[Callable], Callable] +Rule = Tuple[Condition, Callback] +Store = Callable[['Event', Optional[Submission], Submission], + Tuple['Event', Submission]] + + +class EventType(type): + """Metaclass for :class:`.Event`\.""" + + +@dataclass() +class Event(metaclass=EventType): + """ + Base class for submission-related events/commands. + + An event represents a change to a :class:`.domain.submission.Submission`. + Rather than changing submissions directly, an application should create + (and store) events. Each event class must inherit from this base class, + extend it with whatever data is needed for the event, and define methods + for validation and projection (changing a submission): + + - ``validate(self, submission: Submission) -> None`` should raise + :class:`.InvalidEvent` if the event instance has invalid data. + - ``project(self, submission: Submission) -> Submission`` should perform + changes to the :class:`.domain.submission.Submission` and return it. + + An event class also provides a hook for doing things automatically when the + submission changes. To register a function that gets called when an event + is committed, use the :func:`bind` method. + """ + + NAME = 'base event' + NAMED = 'base event' + + creator: Agent + """ + The agent responsible for the operation represented by this event. + + This is **not** necessarily the creator of the submission. + """ + + created: Optional[datetime] = field(default=None) # get_tzaware_utc_now + """The timestamp when the event was originally committed.""" + + proxy: Optional[Agent] = field(default=None) + """ + The agent who facilitated the operation on behalf of the :attr:`.creator`. + + This may be an API client, or another user who has been designated as a + proxy. Note that proxy implies that the creator was not directly involved. + """ + + client: Optional[Agent] = field(default=None) + """ + The client through which the :attr:`.creator` performed the operation. + + If the creator was directly involved in the operation, this property should + be the client that facilitated the operation. + """ + + submission_id: Optional[int] = field(default=None) + """ + The primary identifier of the submission being operated upon. + + This is defined as optional to support creation events, and to facilitate + chaining of events with creation events in the same transaction. + """ + + committed: bool = field(default=False) + """ + Indicates whether the event has been committed to the database. + + This should generally not be set from outside this package. + """ + + before: Optional[Submission] = None + """The state of the submission prior to the event.""" + + after: Optional[Submission] = None + """The state of the submission after the event.""" + + event_type: str = field(default_factory=str) + event_version: str = field(default_factory=str) + + _hooks: ClassVar[Mapping[type, List[Rule]]] = defaultdict(list) + + def __post_init__(self) -> None: + """Make sure data look right.""" + self.event_type = self.get_event_type() + self.event_version = self.get_event_version() + if self.client and isinstance(self.client, dict): + self.client = agent_factory(**self.client) + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + if self.before and isinstance(self.before, dict): + self.before = Submission(**self.before) + if self.after and isinstance(self.after, dict): + self.after = Submission(**self.after) + + @staticmethod + def get_event_version() -> str: + return str(get_application_config().get('CORE_VERSION', '0.0.0')) + + @classmethod + def get_event_type(cls) -> str: + """Get the name of the event type.""" + return cls.__name__ + + @property + def event_id(self) -> str: + """Unique ID for this event.""" + if not self.created: + raise RuntimeError('Event not yet committed') + return self.get_id(self.created, self.event_type, self.creator) + + @staticmethod + def get_id(created: datetime, event_type: str, creator: Agent) -> str: + h = hashlib.new('sha1') + h.update(b'%s:%s:%s' % (created.isoformat().encode('utf-8'), + event_type.encode('utf-8'), + creator.agent_identifier.encode('utf-8'))) + return h.hexdigest() + + def apply(self, submission: Optional[Submission] = None) -> Submission: + """Apply the projection for this :class:`.Event` instance.""" + self.before = copy.deepcopy(submission) + # See comment on CreateSubmission, below. + self.validate(submission) # type: ignore + if submission is not None: + self.after = self.project(copy.deepcopy(submission)) + else: # See comment on CreateSubmission, below. + self.after = self.project(None) # type: ignore + assert self.after is not None + self.after.updated = self.created + + # Make sure that the submission has its own ID, if we know what it is. + if self.after.submission_id is None and self.submission_id is not None: + self.after.submission_id = self.submission_id + if self.submission_id is None and self.after.submission_id is not None: + self.submission_id = self.after.submission_id + return self.after + + @classmethod + def bind(cls, condition: Optional[Condition] = None) -> Decorator: + """ + Generate a decorator to bind a callback to an event type. + + To register a function that will be called whenever an event is + committed, decorate it like so: + + .. code-block:: python + + @MyEvent.bind() + def say_hello(event: MyEvent, before: Submission, + after: Submission) -> Iterable[Event]: + yield SomeOtherEvent(...) + + The callback function will be passed the event that triggered it, the + state of the submission before and after the triggering event was + applied, and a :class:`.System` agent that can be used as the creator + of subsequent events. It should return an iterable of other + :class:`.Event` instances, either by yielding them, or by + returning an iterable object of some kind. + + By default, callbacks will only be called if the creator of the + trigger event is not a :class:`.System` instance. This makes it less + easy to define infinite chains of callbacks. You can pass a custom + condition to the decorator, for example: + + .. code-block:: python + + def jill_created_an_event(event: MyEvent, before: Submission, + after: Submission) -> bool: + return event.creator.username == 'jill' + + + @MyEvent.bind(jill_created_an_event) + def say_hi(event: MyEvent, before: Submission, + after: Submission) -> Iterable[Event]: + yield SomeOtherEvent(...) + + Note that the condition signature is ``(event: MyEvent, before: + Submission, after: Submission) -> bool``\. + + Parameters + ---------- + condition : Callable + A callable with the signature ``(event: Event, before: Submission, + after: Submission) -> bool``. If this callable returns ``True``, + the callback will be triggered when the event to which it is bound + is saved. The default condition is that the event was not created + by :class:`System` + + Returns + ------- + Callable + Decorator for a callback function, with signature ``(event: Event, + before: Submission, after: Submission, creator: Agent = + System(...)) -> Iterable[Event]``. + + """ + if condition is None: + def _creator_is_not_system(e: Event, *ar: Any, **kw: Any) -> bool: + return type(e.creator) is not System + condition = _creator_is_not_system + + def decorator(func: Callback) -> Callback: + """Register a callback for an event type and condition.""" + name = f'{cls.__name__}::{func.__module__}.{func.__name__}' + sys = System(name) + setattr(func, '__name__', name) + + @wraps(func) + def do(event: Event, before: Submission, after: Submission, + creator: Agent = sys, **kwargs: Any) -> Iterable['Event']: + """Perform the callback. Here in case we need to hook in.""" + return func(event, before, after) + + assert condition is not None + cls._add_callback(condition, do) + return do + return decorator + + @classmethod + def _add_callback(cls: Type['Event'], condition: Condition, + callback: Callback) -> None: + cls._hooks[cls].append((condition, callback)) + + def _get_callbacks(self) -> Iterable[Tuple[Condition, Callback]]: + return ((condition, callback) for cls in type(self).__mro__[::-1] + for condition, callback in self._hooks[cls]) + + def _should_apply_callbacks(self) -> bool: + config = get_application_config() + return bool(int(config.get('ENABLE_CALLBACKS', '0'))) + + def validate(self, submission: Submission) -> None: + """Validate this event and its data against a submission.""" + raise NotImplementedError('Must be implemented by subclass') + + def project(self, submission: Submission) -> Submission: + """Apply this event and its data to a submission.""" + raise NotImplementedError('Must be implemented by subclass') + + def commit(self, store: Store) -> Tuple[Submission, Events]: + """ + Persist this event instance using an injected store method. + + Parameters + ---------- + save : Callable + Should have signature ``(*Event, submission_id: int) -> + Tuple[Event, Submission]``. + + Returns + ------- + :class:`Submission` + State of the submission after storage. Some changes may have been + made to ensure consistency with the underlying datastore. + list + Items are :class:`Event` instances. + + """ + assert self.after is not None + _, after = store(self, self.before, self.after) + self.committed = True + if not self._should_apply_callbacks(): + return self.after, [] + consequences: List[Event] = [] + for condition, callback in self._get_callbacks(): + assert self.after is not None + if condition(self, self.before, self.after): + for consequence in callback(self, self.before, self.after): + consequence.created = datetime.now(UTC) + self.after = consequence.apply(self.after) + consequences.append(consequence) + self.after, addl_consequences = consequence.commit(store) + for addl in addl_consequences: + consequences.append(addl) + assert self.after is not None + return self.after, consequences + + +def _get_subclasses(klass: Type[Event]) -> List[Type[Event]]: + _subclasses = klass.__subclasses__() + if _subclasses: + return _subclasses + [sub for klass in _subclasses + for sub in _get_subclasses(klass)] + return _subclasses + + +def event_factory(event_type: str, created: datetime, **data: Any) -> Event: + """ + Generate an :class:`Event` instance from raw :const:`EventData`. + + Parameters + ---------- + event_type : str + Should be the name of a :class:`.Event` subclass. + data : kwargs + Keyword parameters passed to the event constructor. + + Returns + ------- + :class:`.Event` + An instance of an :class:`.Event` subclass. + + """ + etypes = {klas.get_event_type(): klas for klas in _get_subclasses(Event)} + # TODO: typing on version_data is not very good right now. This is not an + # error, but we have two competing ways of using the data that gets passed + # in that need to be reconciled. + version_data: EventData = data # type: ignore + version_data.update({'event_type': event_type}) + data = map_to_current_version(version_data) # type: ignore + event_version = data.pop("event_version", None) + data['created'] = created + if event_type in etypes: + # Mypy gives a spurious 'Too many arguments for "Event"'. + return etypes[event_type](**data) # type: ignore + raise RuntimeError('Unknown event type: %s' % event_type) diff --git a/src/arxiv/submission/domain/event/flag.py b/src/arxiv/submission/domain/event/flag.py new file mode 100644 index 0000000..6d9515e --- /dev/null +++ b/src/arxiv/submission/domain/event/flag.py @@ -0,0 +1,256 @@ +"""Events/commands related to quality assurance.""" + +from typing import Optional, Union + +from dataclasses import field + +from .util import dataclass +from .base import Event +from ..flag import Flag, ContentFlag, MetadataFlag, UserFlag +from ..submission import Submission, SubmissionMetadata, Hold, Waiver +from ...exceptions import InvalidEvent + + +@dataclass() +class AddFlag(Event): + """Base class for flag events; not for direct use.""" + + NAME = "add flag" + NAMED = "flag added" + + flag_data: Optional[Union[int, str, float, dict, list]] \ + = field(default=None) + comment: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Not implemented.""" + raise NotImplementedError("Invoke a child event instead") + + def project(self, submission: Submission) -> Submission: + """Not implemented.""" + raise NotImplementedError("Invoke a child event instead") + + +@dataclass() +class RemoveFlag(Event): + """Remove a :class:`.domain.Flag` from a submission.""" + + NAME = "remove flag" + NAMED = "flag removed" + + flag_id: Optional[str] = field(default=None) + """This is the ``event_id`` of the event that added the flag.""" + + def validate(self, submission: Submission) -> None: + """Verify that the flag exists.""" + if self.flag_id not in submission.flags: + raise InvalidEvent(self, f"Unknown flag: {self.flag_id}") + + def project(self, submission: Submission) -> Submission: + """Remove the flag from the submission.""" + assert self.flag_id is not None + submission.flags.pop(self.flag_id) + return submission + + +@dataclass() +class AddContentFlag(AddFlag): + """Add a :class:`.domain.ContentFlag` related to content.""" + + NAME = "add content flag" + NAMED = "content flag added" + + flag_type: Optional[ContentFlag.FlagType] = None + + def validate(self, submission: Submission) -> None: + """Verify that we have a known flag.""" + if self.flag_type not in ContentFlag.FlagType: + raise InvalidEvent(self, f"Unknown content flag: {self.flag_type}") + + def project(self, submission: Submission) -> Submission: + """Add the flag to the submission.""" + assert self.created is not None + submission.flags[self.event_id] = ContentFlag( + event_id=self.event_id, + created=self.created, + creator=self.creator, + proxy=self.proxy, + flag_type=self.flag_type, + flag_data=self.flag_data, + comment=self.comment or '' + ) + return submission + + def __post_init__(self) -> None: + """Make sure that `flag_type` is an enum instance.""" + if type(self.flag_type) is str: + self.flag_type = ContentFlag.FlagType(self.flag_type) + super(AddContentFlag, self).__post_init__() + + +@dataclass() +class AddMetadataFlag(AddFlag): + """Add a :class:`.domain.MetadataFlag` related to the metadata.""" + + NAME = "add metadata flag" + NAMED = "metadata flag added" + + flag_type: Optional[MetadataFlag.FlagType] = field(default=None) + field: Optional[str] = field(default=None) + """Name of the metadata field to which the flag applies.""" + + def validate(self, submission: Submission) -> None: + """Verify that we have a known flag and metadata field.""" + if self.flag_type not in MetadataFlag.FlagType: + raise InvalidEvent(self, f"Unknown meta flag: {self.flag_type}") + if self.field and not hasattr(SubmissionMetadata, self.field): + raise InvalidEvent(self, "Not a valid metadata field") + + def project(self, submission: Submission) -> Submission: + """Add the flag to the submission.""" + assert self.created is not None + submission.flags[self.event_id] = MetadataFlag( + event_id=self.event_id, + created=self.created, + creator=self.creator, + proxy=self.proxy, + flag_type=self.flag_type, + flag_data=self.flag_data, + comment=self.comment or '', + field=self.field + ) + return submission + + def __post_init__(self) -> None: + """Make sure that `flag_type` is an enum instance.""" + if type(self.flag_type) is str: + self.flag_type = MetadataFlag.FlagType(self.flag_type) + super(AddMetadataFlag, self).__post_init__() + + +@dataclass() +class AddUserFlag(AddFlag): + """Add a :class:`.domain.UserFlag` related to the submitter.""" + + NAME = "add user flag" + NAMED = "user flag added" + + flag_type: Optional[UserFlag.FlagType] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Verify that we have a known flag.""" + if self.flag_type not in MetadataFlag.FlagType: + raise InvalidEvent(self, f"Unknown user flag: {self.flag_type}") + + def project(self, submission: Submission) -> Submission: + """Add the flag to the submission.""" + assert self.flag_type is not None + assert self.created is not None + submission.flags[self.event_id] = UserFlag( + event_id=self.event_id, + created=self.created, + creator=self.creator, + flag_type=self.flag_type, + flag_data=self.flag_data, + comment=self.comment or '' + ) + return submission + + def __post_init__(self) -> None: + """Make sure that `flag_type` is an enum instance.""" + if type(self.flag_type) is str: + self.flag_type = UserFlag.FlagType(self.flag_type) + super(AddUserFlag, self).__post_init__() + + +@dataclass() +class AddHold(Event): + """Add a :class:`.Hold` to a :class:`.Submission`.""" + + NAME = "add hold" + NAMED = "hold added" + + hold_type: Hold.Type = field(default=Hold.Type.PATCH) + hold_reason: Optional[str] = field(default_factory=str) + + def validate(self, submission: Submission) -> None: + pass + + def project(self, submission: Submission) -> Submission: + """Add the hold to the submission.""" + assert self.created is not None + submission.holds[self.event_id] = Hold( + event_id=self.event_id, + created=self.created, + creator=self.creator, + hold_type=self.hold_type, + hold_reason=self.hold_reason + ) + # submission.status = Submission.ON_HOLD + return submission + + def __post_init__(self) -> None: + """Make sure that `hold_type` is an enum instance.""" + if type(self.hold_type) is str: + self.hold_type = Hold.Type(self.hold_type) + super(AddHold, self).__post_init__() + + +@dataclass() +class RemoveHold(Event): + """Remove a :class:`.Hold` from a :class:`.Submission`.""" + + NAME = "remove hold" + NAMED = "hold removed" + + hold_event_id: str = field(default_factory=str) + hold_type: Hold.Type = field(default=Hold.Type.PATCH) + removal_reason: Optional[str] = field(default_factory=str) + + def validate(self, submission: Submission) -> None: + if self.hold_event_id not in submission.holds: + raise InvalidEvent(self, "No such hold") + + def project(self, submission: Submission) -> Submission: + """Remove the hold from the submission.""" + submission.holds.pop(self.hold_event_id) + # submission.status = Submission.SUBMITTED + return submission + + def __post_init__(self) -> None: + """Make sure that `hold_type` is an enum instance.""" + if type(self.hold_type) is str: + self.hold_type = Hold.Type(self.hold_type) + super(RemoveHold, self).__post_init__() + + +@dataclass() +class AddWaiver(Event): + """Add a :class:`.Waiver` to a :class:`.Submission`.""" + + NAME = "add waiver" + NAMED = "waiver added" + + waiver_type: Hold.Type = field(default=Hold.Type.SOURCE_OVERSIZE) + waiver_reason: str = field(default_factory=str) + + def validate(self, submission: Submission) -> None: + pass + + def project(self, submission: Submission) -> Submission: + """Add the :class:`.Waiver` to the :class:`.Submission`.""" + assert self.created is not None + submission.waivers[self.event_id] = Waiver( + event_id=self.event_id, + created=self.created, + creator=self.creator, + waiver_type=self.waiver_type, + waiver_reason=self.waiver_reason + ) + return submission + + def __post_init__(self) -> None: + """Make sure that `waiver_type` is an enum instance.""" + if type(self.waiver_type) is str: + self.waiver_type = Hold.Type(self.waiver_type) + super(AddWaiver, self).__post_init__() diff --git a/src/arxiv/submission/domain/event/process.py b/src/arxiv/submission/domain/event/process.py new file mode 100644 index 0000000..51fa900 --- /dev/null +++ b/src/arxiv/submission/domain/event/process.py @@ -0,0 +1,51 @@ +"""Events related to external or long-running processes.""" + +from typing import Optional + +from dataclasses import field + +from ...exceptions import InvalidEvent +from ..submission import Submission +from ..process import ProcessStatus +from .base import Event +from .util import dataclass + + +@dataclass() +class AddProcessStatus(Event): + """Add the status of an external/long-running process to a submission.""" + + NAME = "add status of a process" + NAMED = "added status of a process" + + # Status = ProcessStatus.Status + + process_id: Optional[str] = field(default=None) + process: Optional[str] = field(default=None) + step: Optional[str] = field(default=None) + status: ProcessStatus.Status = field(default=ProcessStatus.Status.PENDING) + reason: Optional[str] = field(default=None) + + def __post_init__(self) -> None: + """Make sure our enums are in order.""" + super(AddProcessStatus, self).__post_init__() + self.status = ProcessStatus.Status(self.status) + + def validate(self, submission: Submission) -> None: + """Verify that we have a :class:`.ProcessStatus`.""" + if self.process is None: + raise InvalidEvent(self, "Must include process") + + def project(self, submission: Submission) -> Submission: + """Add the process status to the submission.""" + assert self.created is not None + assert self.process is not None + submission.processes.append(ProcessStatus( + creator=self.creator, + created=self.created, + process=self.process, + step=self.step, + status=self.status, + reason=self.reason + )) + return submission diff --git a/src/arxiv/submission/domain/event/proposal.py b/src/arxiv/submission/domain/event/proposal.py new file mode 100644 index 0000000..76194c8 --- /dev/null +++ b/src/arxiv/submission/domain/event/proposal.py @@ -0,0 +1,148 @@ +"""Commands for working with :class:`.Proposal` instances on submissions.""" + +import hashlib +import re +import copy +from datetime import datetime +from pytz import UTC +from typing import Optional, TypeVar, List, Tuple, Any, Dict, Iterable +from urllib.parse import urlparse +from dataclasses import field, asdict +from .util import dataclass +import bleach + +from arxiv import taxonomy +from arxiv.base import logging + +from ..agent import Agent +from ..submission import Submission, SubmissionMetadata, Author, \ + Classification, License, Delegation, \ + SubmissionContent, WithdrawalRequest, CrossListClassificationRequest +from ..proposal import Proposal +from ..annotation import Comment + +from ...exceptions import InvalidEvent +from ..util import get_tzaware_utc_now +from .base import Event +from .request import RequestCrossList, RequestWithdrawal, ApplyRequest, \ + RejectRequest, ApproveRequest +from . import validators + +logger = logging.getLogger(__name__) + + +@dataclass() +class AddProposal(Event): + """Add a new proposal to a :class:`Submission`.""" + + NAME = 'add proposal' + NAMED = 'proposal added' + + proposed_event_type: Optional[type] = field(default=None) + proposed_event_data: dict = field(default_factory=dict) + comment: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Simulate applying the proposal to check for validity.""" + if self.proposed_event_type is None: + raise InvalidEvent(self, f"Proposed event type is required") + proposed_event_data = copy.deepcopy(self.proposed_event_data) + proposed_event_data.update({'creator': self.creator}) + event = self.proposed_event_type(**proposed_event_data) + event.validate(submission) + + def project(self, submission: Submission) -> Submission: + """Add the proposal to the submission.""" + assert self.created is not None + submission.proposals[self.event_id] = Proposal( + event_id=self.event_id, + creator=self.creator, + created=self.created, + proxy=self.proxy, + proposed_event_type=self.proposed_event_type, + proposed_event_data=self.proposed_event_data, + comments=[Comment(event_id=self.event_id, creator=self.creator, + created=self.created, proxy=self.proxy, + body=self.comment or '')], + status=Proposal.Status.PENDING + ) + return submission + + +@dataclass() +class RejectProposal(Event): + """Reject a :class:`.Proposal` on a submission.""" + + NAME = 'reject proposal' + NAMED = 'proposal rejected' + + proposal_id: Optional[str] = field(default=None) + comment: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Ensure that the proposal isn't already approved or rejected.""" + if self.proposal_id not in submission.proposals: + raise InvalidEvent(self, f"No such proposal {self.proposal_id}") + elif submission.proposals[self.proposal_id].is_rejected(): + raise InvalidEvent(self, f"{self.proposal_id} is already rejected") + elif submission.proposals[self.proposal_id].is_accepted(): + raise InvalidEvent(self, f"{self.proposal_id} is accepted") + + def project(self, submission: Submission) -> Submission: + """Set the status of the proposal to rejected.""" + assert self.proposal_id is not None + assert self.created is not None + submission.proposals[self.proposal_id].status = Proposal.Status.REJECTED + if self.comment: + submission.proposals[self.proposal_id].comments.append( + Comment(event_id=self.event_id, creator=self.creator, + created=self.created, proxy=self.proxy, + body=self.comment)) + return submission + + +@dataclass() +class AcceptProposal(Event): + """Accept a :class:`.Proposal` on a submission.""" + + NAME = 'accept proposal' + NAMED = 'proposal accepted' + + proposal_id: Optional[str] = field(default=None) + comment: Optional[str] = field(default=None) + + def validate(self, submission: Submission) -> None: + """Ensure that the proposal isn't already approved or rejected.""" + if self.proposal_id not in submission.proposals: + raise InvalidEvent(self, f"No such proposal {self.proposal_id}") + elif submission.proposals[self.proposal_id].is_rejected(): + raise InvalidEvent(self, f"{self.proposal_id} is rejected") + elif submission.proposals[self.proposal_id].is_accepted(): + raise InvalidEvent(self, f"{self.proposal_id} is already accepted") + + def project(self, submission: Submission) -> Submission: + """Mark the proposal as accepted.""" + assert self.created is not None + assert self.proposal_id is not None + submission.proposals[self.proposal_id].status \ + = Proposal.Status.ACCEPTED + if self.comment: + submission.proposals[self.proposal_id].comments.append( + Comment(event_id=self.event_id, creator=self.creator, + created=self.created, proxy=self.proxy, + body=self.comment)) + return submission + + +@AcceptProposal.bind() +def apply_proposal(event: AcceptProposal, before: Submission, + after: Submission, creator: Agent) -> Iterable[Event]: + """Apply an accepted proposal.""" + assert event.proposal_id is not None + proposal = after.proposals[event.proposal_id] + proposed_event_data = copy.deepcopy(proposal.proposed_event_data) + proposed_event_data.update({'creator': creator}) + + assert proposal.proposed_event_type is not None + event = proposal.proposed_event_type(**proposed_event_data) + yield event diff --git a/src/arxiv/submission/domain/event/request.py b/src/arxiv/submission/domain/event/request.py new file mode 100644 index 0000000..3b09762 --- /dev/null +++ b/src/arxiv/submission/domain/event/request.py @@ -0,0 +1,224 @@ +"""Commands/events related to user requests.""" + +from typing import Optional, List +import hashlib +from dataclasses import field +from .util import dataclass + +from arxiv import taxonomy + +from . import validators +from .base import Event +from ..submission import Submission, Classification, WithdrawalRequest, \ + CrossListClassificationRequest, UserRequest +from ...exceptions import InvalidEvent + + +@dataclass() +class ApproveRequest(Event): + """Approve a user request.""" + + NAME = "approve user request" + NAMED = "user request approved" + + request_id: Optional[str] = field(default=None) + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + if self.request_id not in submission.user_requests: + raise InvalidEvent(self, "No such request") + + def project(self, submission: Submission) -> Submission: + assert self.request_id is not None + submission.user_requests[self.request_id].status = UserRequest.APPROVED + return submission + + +@dataclass() +class RejectRequest(Event): + NAME = "reject user request" + NAMED = "user request rejected" + + request_id: Optional[str] = field(default=None) + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + if self.request_id not in submission.user_requests: + raise InvalidEvent(self, "No such request") + + def project(self, submission: Submission) -> Submission: + assert self.request_id is not None + submission.user_requests[self.request_id].status = UserRequest.REJECTED + return submission + + +@dataclass() +class CancelRequest(Event): + NAME = "cancel user request" + NAMED = "user request cancelled" + + request_id: Optional[str] = field(default=None) + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + if self.request_id not in submission.user_requests: + raise InvalidEvent(self, "No such request") + + def project(self, submission: Submission) -> Submission: + assert self.request_id is not None + submission.user_requests[self.request_id].status = \ + UserRequest.CANCELLED + return submission + + +@dataclass() +class ApplyRequest(Event): + NAME = "apply user request" + NAMED = "user request applied" + + request_id: Optional[str] = field(default=None) + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + if self.request_id not in submission.user_requests: + raise InvalidEvent(self, "No such request") + + def project(self, submission: Submission) -> Submission: + assert self.request_id is not None + user_request = submission.user_requests[self.request_id] + if hasattr(user_request, 'apply'): + submission = user_request.apply(submission) + user_request.status = UserRequest.APPLIED + submission.user_requests[self.request_id] = user_request + return submission + + +@dataclass() +class RequestCrossList(Event): + """Request that a secondary classification be added after announcement.""" + + NAME = "request cross-list classification" + NAMED = "cross-list classification requested" + + categories: List[taxonomy.Category] = field(default_factory=list) + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + """Validate the cross-list request.""" + validators.no_active_requests(self, submission) + if not submission.is_announced: + raise InvalidEvent(self, "Submission must already be announced") + for category in self.categories: + validators.must_be_an_active_category(self, category, submission) + validators.cannot_be_primary(self, category, submission) + validators.cannot_be_secondary(self, category, submission) + + def project(self, submission: Submission) -> Submission: + """Create a cross-list request.""" + classifications = [ + Classification(category=category) for category in self.categories + ] + + req_id = CrossListClassificationRequest.generate_request_id(submission) + assert self.created is not None + user_request = CrossListClassificationRequest( + request_id=req_id, + creator=self.creator, + created=self.created, + status=WithdrawalRequest.PENDING, + classifications=classifications + ) + submission.user_requests[req_id] = user_request + return submission + + +@dataclass() +class RequestWithdrawal(Event): + """Request that a paper be withdrawn.""" + + NAME = "request withdrawal" + NAMED = "withdrawal requested" + + reason: str = field(default_factory=str) + + MAX_LENGTH = 400 + + def __hash__(self) -> int: + """Use event ID as object hash.""" + return hash(self.event_id) + + def __eq__(self, other: object) -> bool: + """Compare this event to another event.""" + if not isinstance(other, Event): + return NotImplemented + return hash(self) == hash(other) + + def validate(self, submission: Submission) -> None: + """Make sure that a reason was provided.""" + validators.no_active_requests(self, submission) + if not self.reason: + raise InvalidEvent(self, "Provide a reason for the withdrawal") + if len(self.reason) > self.MAX_LENGTH: + raise InvalidEvent(self, "Reason must be 400 characters or less") + if not submission.is_announced: + raise InvalidEvent(self, "Submission must already be announced") + + def project(self, submission: Submission) -> Submission: + """Update the submission status and withdrawal reason.""" + assert self.created is not None + req_id = WithdrawalRequest.generate_request_id(submission) + user_request = WithdrawalRequest( + request_id=req_id, + creator=self.creator, + created=self.created, + updated=self.created, + status=WithdrawalRequest.PENDING, + reason_for_withdrawal=self.reason + ) + submission.user_requests[req_id] = user_request + return submission diff --git a/src/arxiv/submission/domain/event/tests/__init__.py b/src/arxiv/submission/domain/event/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py b/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py new file mode 100644 index 0000000..1c4b922 --- /dev/null +++ b/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py @@ -0,0 +1,52 @@ +"""Test abstract cleanup""" + +from unittest import TestCase +from .. import SetAbstract +from arxiv.base.filters import abstract_lf_to_br + +class TestSetAbstractCleanup(TestCase): + """Test abstract cleanup""" + + def test_paragraph_cleanup(self): + awlb = "Paragraph 1.\n \nThis should be paragraph 2" + self.assertIn(' in') + + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n\t\nThis should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace (tab)') + + awlb = "Paragraph 1.\n \nThis should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n \t \nThis should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n \nThis should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n This should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n\tThis should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') + + awlb = "Paragraph 1.\n This should be p 2." + e = SetAbstract(creator='xyz', abstract=awlb) + self.assertIn(' creating whitespace') diff --git a/src/arxiv/submission/domain/event/tests/test_event_construction.py b/src/arxiv/submission/domain/event/tests/test_event_construction.py new file mode 100644 index 0000000..51de87c --- /dev/null +++ b/src/arxiv/submission/domain/event/tests/test_event_construction.py @@ -0,0 +1,45 @@ +"""Test that all event classes are well-formed.""" + +from unittest import TestCase +import inspect +from ..base import Event + + +class TestNamed(TestCase): + """Verify that all event classes are named.""" + + def test_has_name(self): + """All event classes must have a ``NAME`` attribute.""" + for klass in Event.__subclasses__(): + self.assertTrue(hasattr(klass, 'NAME'), + f'{klass.__name__} is missing attribute NAME') + + def test_has_named(self): + """All event classes must have a ``NAMED`` attribute.""" + for klass in Event.__subclasses__(): + self.assertTrue(hasattr(klass, 'NAMED'), + f'{klass.__name__} is missing attribute NAMED') + + +class TestHasProjection(TestCase): + """Verify that all event classes have a projection method.""" + + def test_has_projection(self): + """Each event class must have an instance method ``project()``.""" + for klass in Event.__subclasses__(): + self.assertTrue(hasattr(klass, 'project'), + f'{klass.__name__} is missing project() method') + self.assertTrue(inspect.isfunction(klass.project), + f'{klass.__name__} is missing project() method') + + +class TestHasValidation(TestCase): + """Verify that all event classes have a projection method.""" + + def test_has_validate(self): + """Each event class must have an instance method ``validate()``.""" + for klass in Event.__subclasses__(): + self.assertTrue(hasattr(klass, 'validate'), + f'{klass.__name__} is missing validate() method') + self.assertTrue(inspect.isfunction(klass.validate), + f'{klass.__name__} is missing validate() method') diff --git a/src/arxiv/submission/domain/event/tests/test_hooks.py b/src/arxiv/submission/domain/event/tests/test_hooks.py new file mode 100644 index 0000000..aa960a4 --- /dev/null +++ b/src/arxiv/submission/domain/event/tests/test_hooks.py @@ -0,0 +1,61 @@ +"""Test callback hook functionality on :class:`Event`.""" + +from unittest import TestCase, mock +from dataclasses import dataclass, field +from ..base import Event +from ...agent import System + + +class TestCommitEvent(TestCase): + """Tests for :func:`Event.bind` and :class:`Event.commit`.""" + + def test_commit_event(self): + """Test a simple commit hook.""" + @dataclass + class ChildEvent(Event): + def _should_apply_callbacks(self): + return True + + @dataclass + class OtherChildEvent(Event): + def _should_apply_callbacks(self): + return True + + callback = mock.MagicMock(return_value=[], __name__='test') + ChildEvent.bind(lambda *a: True)(callback) # Register callback. + + save = mock.MagicMock( + return_value=(mock.MagicMock(), mock.MagicMock()) + ) + event = ChildEvent(creator=System('system')) + event.after = mock.MagicMock() + OtherChildEvent(creator=System('system')) + event.commit(save) + self.assertEqual(callback.call_count, 1, + "Callback is only executed on the class to which it" + " is bound") + + def test_callback_inheritance(self): + """Callback is inherited by subclasses.""" + @dataclass + class ParentEvent(Event): + def _should_apply_callbacks(self): + return True + + @dataclass + class ChildEvent(ParentEvent): + def _should_apply_callbacks(self): + return True + + callback = mock.MagicMock(return_value=[], __name__='test') + ParentEvent.bind(lambda *a: True)(callback) # Register callback. + + save = mock.MagicMock( + return_value=(mock.MagicMock(), mock.MagicMock()) + ) + event = ChildEvent(creator=System('system')) + event.after = mock.MagicMock() + event.commit(save) + self.assertEqual(callback.call_count, 1, + "Callback bound to parent class is called when child" + " is committed") diff --git a/src/arxiv/submission/domain/event/util.py b/src/arxiv/submission/domain/event/util.py new file mode 100644 index 0000000..19e8a78 --- /dev/null +++ b/src/arxiv/submission/domain/event/util.py @@ -0,0 +1,27 @@ +"""Helpers for event classes.""" + +from typing import Any, Callable + +from dataclasses import dataclass as base_dataclass + + +def event_hash(instance: Any) -> int: + """Use event ID as object hash.""" + return hash(instance.event_id) # typing: ignore + + +def event_eq(instance: Any, other: Any) -> bool: + """Compare this event to another event.""" + return hash(instance) == hash(other) + + +def dataclass(**kwargs: Any) -> Callable[[Any], Any]: + def inner(cls: type) -> Any: + if kwargs: + new_cls = base_dataclass(**kwargs)(cls) + else: + new_cls = base_dataclass(cls) + setattr(new_cls, '__hash__', event_hash) + setattr(new_cls, '__eq__', event_eq) + return new_cls + return inner diff --git a/src/arxiv/submission/domain/event/validators.py b/src/arxiv/submission/domain/event/validators.py new file mode 100644 index 0000000..406d986 --- /dev/null +++ b/src/arxiv/submission/domain/event/validators.py @@ -0,0 +1,128 @@ +"""Reusable validators for events.""" + +import re + +from arxiv.taxonomy import CATEGORIES, CATEGORIES_ACTIVE + +from .base import Event +from ..submission import Submission +from ...exceptions import InvalidEvent + + +def submission_is_not_finalized(event: Event, submission: Submission) -> None: + """ + Verify that the submission is not finalized. + + Parameters + ---------- + event : :class:`.Event` + submission : :class:`.domain.submission.Submission` + + Raises + ------ + :class:`.InvalidEvent` + Raised if the submission is finalized. + + """ + if submission.is_finalized: + raise InvalidEvent(event, "Cannot apply to a finalized submission") + + +def no_trailing_period(event: Event, submission: Submission, + value: str) -> None: + """ + Verify that there are no trailing periods in ``value`` except ellipses. + """ + if re.search(r"(? None: + """Valid arXiv categories are defined in :mod:`arxiv.taxonomy`.""" + if not category or category not in CATEGORIES_ACTIVE: + raise InvalidEvent(event, "Not a valid category") + + +def cannot_be_primary(event: Event, category: str, submission: Submission) \ + -> None: + """The category can't already be set as a primary classification.""" + if submission.primary_classification is None: + return + if category == submission.primary_classification.category: + raise InvalidEvent(event, "The same category cannot be used as both" + " the primary and a secondary category.") + + +def cannot_be_secondary(event: Event, category: str, submission: Submission) \ + -> None: + """The same category cannot be added as a secondary twice.""" + if category in submission.secondary_categories: + raise InvalidEvent(event, f"Secondary {category} already set on this" + f" submission.") + + +def no_active_requests(event: Event, submission: Submission) -> None: + """Must not have active requests""" + if submission.has_active_requests: + raise InvalidEvent(event, "Must not have active requests.") + + +def cannot_be_genph(event: Event, category: str, submission: Submission)\ + -> None: + "Cannot be physics.gen-ph." + if category and category == 'physics.gen-ph': + raise InvalidEvent(event, "Cannot be physics.gen-ph.") + + +def no_redundant_general_category(event: Event, + category: str, + submission: Submission) -> None: + """Prevents adding a general category when another category in + that archive is already represented.""" + if CATEGORIES[category]['is_general']: + if((submission.primary_classification and + CATEGORIES[category]['in_archive'] == + CATEGORIES[submission.primary_category]['in_archive']) + or + (CATEGORIES[category]['in_archive'] + in [CATEGORIES[cat]['in_archive'] for + cat in submission.secondary_categories])): + raise InvalidEvent(event, + f"Cannot add general category {category}" + f" due to more specific category from" + f" {CATEGORIES[category]['in_archive']}.") + + +def no_redundant_non_general_category(event: Event, + category: str, + submission: Submission) -> None: + """Prevents adding a category when a general category in that archive + is already represented.""" + if not CATEGORIES[category]['is_general']: + e_archive = CATEGORIES[category]['in_archive'] + if(submission.primary_classification and + e_archive == + CATEGORIES[submission.primary_category]['in_archive'] + and CATEGORIES[submission.primary_category]['is_general']): + raise InvalidEvent(event, + f'Cannot add more specific {category} due' + f' to general primary.') + + sec_archs = [tcat['in_archive'] for tcat in + [CATEGORIES[cat] + for cat in submission.secondary_categories] + if tcat['is_general']] + if e_archive in sec_archs: + raise InvalidEvent(event, + f'Cannot add more spcific {category} due' + f' to general secondaries.') + + +def max_secondaries(event: Event, submission: Submission) -> None: + "No more than 4 secondary categories per submission." + if (submission.secondary_classification and + len(submission.secondary_classification) + 1 > 4): + raise InvalidEvent( + event, "No more than 4 secondary categories per submission.") diff --git a/src/arxiv/submission/domain/event/versioning/__init__.py b/src/arxiv/submission/domain/event/versioning/__init__.py new file mode 100644 index 0000000..7b163ac --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/__init__.py @@ -0,0 +1,131 @@ +""" +Provides on-the-fly versioned migrations for event data. + +The purpose of this module is to facilitate backwards-compatible changes to +the structure of :class:`.domain.event.Event` classes. This problem is similar +to database migrations, except that the "meat" of the event data are dicts +stored as JSON and thus ALTER commands won't get us all that far. + +Writing version mappings +======================== +Any new version of this software that includes changes to existing +event/command classes that would break events from earlier versions **MUST** +include a version mapping module. The module should include a mapping class +(a subclass of :class:`.BaseVersionMapping`) for each event type for which +there are relevant changes. + +See :mod:`.versioning.version_0_0_0_example` for an example. + +Each such class must include an internal ``Meta`` class with its software +version and the name of the event type to which it applies. For example: + +.. code-block:: python + + from ._base import BaseVersionMapping + + class SetAbstractMigration(BaseVersionMapping): + class Meta: + event_version = "0.2.12" # Must be a semver. + event_type = "SetAbstract" + + +In addition, it's a good idea to include some test data that can be used to +verify the behavior of the migration. You can do this by adding a ``tests`` +attribute to ``Meta`` that includes tuples of the form +``(original: EventData, expected: EventData)``. For example: + + +.. code-block:: python + + from ._base import BaseVersionMapping + + class SetAbstractMigration(BaseVersionMapping): + class Meta: + event_version = "0.2.12" # Must be a semver. + event_type = "SetAbstract" + tests = [({"event_version": "0.2.11", "abstract": "very abstract"}, + {"event_version": "0.2.12", "abstract": "more abstract"})] + + +Transformation logic can be implemented for individual fields, or for the event +datum as a whole. + +Transforming individual fields +------------------------------ +Transformers for individual fields may be implemented by +defining instance methods with the name ``transform_{field}`` and the signature +``(self, original: EventData, key: str, value: Any) -> Tuple[str, Any]``. +The return value is the field name and transformed value. Note that the field +name may be altered here, and the original field name will be omitted from the +final transformed representation of the event datum. + +Transforming the datum as a whole +--------------------------------- +A transformer for the datum as a whole may be implemented by defining an +instance method named ``transform`` with the signature +``(self, original: EventData, transformed: EventData) -> EventData``. This is +called **after** the transformers for individual fields; the second positional +argument is the state of the datum at that point, and the first positional +argument is the state of the datum before transformations were applied. +""" + +import copy +from ._base import EventData, BaseVersionMapping, Version + +from arxiv.base.globals import get_application_config + + +def map_to_version(original: EventData, target: str) -> EventData: + """ + Map raw event data to a later version. + + Loads all version mappings for the original event type subsequent to the + version of the software at which the data was created, up to and + includiong the ``target`` version. + + Parameters + ---------- + original : dict + Original event data. + target : str + The target software version. Must be a valid semantic version, i.e. + with major, minor, and patch components. + + Returns + ------- + dict + Data from ``original`` transformed into a representation suitable for + use in the target software version. + + """ + original_version = Version.from_event_data(original) + transformed = copy.deepcopy(original) + for mapping in BaseVersionMapping.__subclasses__(): + if original['event_type'] == mapping.Meta.event_type \ + and Version(mapping.Meta.event_version) <= Version(target) \ + and Version(mapping.Meta.event_version) > original_version: + mapper = mapping() + transformed = mapper(transformed) + return transformed + + +def map_to_current_version(original: EventData) -> EventData: + """ + Map raw event data to the current software version. + + Relies on the ``CORE_VERSION`` parameter in the application configuration. + + Parameters + ---------- + original : dict + Original event data. + + Returns + ------- + dict + Data from ``original`` transformed into a representation suitable for + use in the current software version. + + """ + current_version = get_application_config().get('CORE_VERSION', '0.0.0') + return map_to_version(original, current_version) diff --git a/src/arxiv/submission/domain/event/versioning/_base.py b/src/arxiv/submission/domain/event/versioning/_base.py new file mode 100644 index 0000000..05a7712 --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/_base.py @@ -0,0 +1,121 @@ +"""Provides :class:`.BaseVersionMapping`.""" + +from typing import Optional, Callable, Any, Tuple +from datetime import datetime +from mypy_extensions import TypedDict +import semver + + +class EventData(TypedDict, total=False): + """Raw event data from the event store.""" + + event_version: str + created: datetime + event_type: str + + +class Version(str): + """A semantic version.""" + + @classmethod + def from_event_data(cls, data: EventData) -> 'Version': + """Create a :class:`.Version` from :class:`.EventData`.""" + return cls(data['event_version']) + + def __eq__(self, other: object) -> bool: + """Equality comparison using semantic versioning.""" + if not isinstance(other, str): + return NotImplemented + return bool(semver.compare(self, other) == 0) + + def __lt__(self, other: object) -> bool: + """Less-than comparison using semantic versioning.""" + if not isinstance(other, str): + return NotImplemented + return bool(semver.compare(self, other) < 0) + + def __le__(self, other: object) -> bool: + """Less-than-equals comparison using semantic versioning.""" + if not isinstance(other, str): + return NotImplemented + return bool(semver.compare(self, other) <= 0) + + def __gt__(self, other: object) -> bool: + """Greater-than comparison using semantic versioning.""" + if not isinstance(other, str): + return NotImplemented + return bool(semver.compare(self, other) > 0) + + def __ge__(self, other: object) -> bool: + """Greater-than-equals comparison using semantic versioning.""" + if not isinstance(other, str): + return NotImplemented + return bool(semver.compare(self, other) >= 0) + + +FieldTransformer = Callable[[EventData, str, Any], Tuple[str, Any]] + + +class BaseVersionMapping: + """Base class for version mappings.""" + + _protected = ['event_type', 'event_version', 'created'] + + class Meta: + event_version = None + event_type = None + + def __init__(self) -> None: + """Verify that the instance has required metadata.""" + if not hasattr(self, 'Meta'): + raise NotImplementedError('Missing `Meta` on child class') + if getattr(self.Meta, 'event_version', None) is None: + raise NotImplementedError('Missing version on child class') + if getattr(self.Meta, 'event_type', None) is None: + raise NotImplementedError('Missing event_type on child class') + + def __call__(self, original: EventData) -> EventData: + """Transform some :class:`.EventData`.""" + return self._transform(original) + + @classmethod + def test(cls) -> None: + """Perform tests on the mapping subclass.""" + try: + cls() + except NotImplementedError as e: + raise AssertionError('Not correctly implemented') from e + for original, expected in getattr(cls.Meta, 'tests', []): + assert cls()(original) == expected + try: + semver.parse_version_info(cls.Meta.event_version) + except ValueError as e: + raise AssertionError('Not a valid semantic version') from e + + def _get_field_transformer(self, field: str) -> Optional[FieldTransformer]: + """Get a transformation for a field, if it is defined.""" + tx: Optional[FieldTransformer] \ + = getattr(self, f'transform_{field}', None) + return tx + + def transform(self, orig: EventData, xf: EventData) -> EventData: + """Transform the event data as a whole.""" + return xf # Nothing to do; subclasses can reimplement for fun/profit. + + def _transform(self, original: EventData) -> EventData: + """Perform transformation of event data.""" + transformed = EventData() + for key, value in original.items(): + if key not in self._protected: + field_transformer = self._get_field_transformer(key) + if field_transformer is not None: + key, value = field_transformer(original, key, value) + # Mypy wants they key to be a string literal here, which runs + # against the pattern implemented here. We could consider not + # using a TypedDict. This code is correct for now, just not ideal + # for type-checking. + transformed[key] = value # type: ignore + transformed = self.transform(original, transformed) + assert self.Meta.event_version is not None + transformed['event_version'] = self.Meta.event_version + return transformed diff --git a/src/arxiv/submission/domain/event/versioning/tests/__init__.py b/src/arxiv/submission/domain/event/versioning/tests/__init__.py new file mode 100644 index 0000000..c041a0d --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for versioning mechanisms.""" diff --git a/src/arxiv/submission/domain/event/versioning/tests/test_example.py b/src/arxiv/submission/domain/event/versioning/tests/test_example.py new file mode 100644 index 0000000..83be437 --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/tests/test_example.py @@ -0,0 +1,15 @@ +"""Test the example version mapping module.""" + +from unittest import TestCase + +from .. import map_to_version +from .._base import BaseVersionMapping +from .. import version_0_0_0_example + + +class TestSetTitleExample(TestCase): + """Test the :class:`.version_0_0_0_example.SetTitleExample` mapping.""" + + def test_set_title(self): + """Execute the built-in version mapping tests.""" + version_0_0_0_example.SetTitleExample.test() diff --git a/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py b/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py new file mode 100644 index 0000000..e82c097 --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py @@ -0,0 +1,136 @@ +"""Test versioning of event data.""" + +from unittest import TestCase + +from .. import map_to_version +from .._base import BaseVersionMapping + + +class TitleIsNowCoolTitle(BaseVersionMapping): + """Changes the ``title`` field to ``cool_title``.""" + + class Meta: + """Metadata for this mapping.""" + + event_version = '0.3.5' + event_type = "SetTitle" + tests = [({'event_version': '0.1.1', 'title': 'olde'}, + {'event_version': '0.3.5', 'cool_title': 'olde'})] + + def transform_title(self, original, key, value): + """Rename the `title` field to `cool_title`.""" + return "cool_title", value + + +class TestVersionMapping(TestCase): + """Tests for :func:`.map_to_version`.""" + + def test_map_to_version(self): + """We have data from a previous version and an intermediate mapping.""" + data = { + 'event_version': '0.1.2', + 'event_type': 'SetTitle', + 'title': 'Some olde title' + } + + expected = { + 'event_version': '0.3.5', + 'event_type': 'SetTitle', + 'cool_title': 'Some olde title' + } + self.assertDictEqual(map_to_version(data, '0.4.1'), expected, + "The mapping is applied") + + def test_map_to_version_no_intermediate(self): + """We have data from a previous version and no intermediate mapping.""" + data = { + 'event_version': '0.5.5', + 'event_type': 'SetTitle', + 'cool_title': 'Some olde title' + } + self.assertDictEqual(map_to_version(data, '0.6.7'), data, + "The mapping is not applied") + + def test_data_is_up_to_date(self): + """We have data that is 100% current.""" + data = { + 'event_version': '0.5.5', + 'event_type': 'SetTitle', + 'cool_title': 'Some olde title' + } + self.assertDictEqual(map_to_version(data, '0.5.5'), data, + "The mapping is not applied") + + +class TestVersionMappingTests(TestCase): + """Tests defined in metadata can be run, with the expected result.""" + + def test_test(self): + """Run tests in mapping metadata.""" + class BrokenFitleIsNowCoolTitle(BaseVersionMapping): + """A broken version mapping.""" + + class Meta: + """Metadata for this mapping.""" + + event_version = '0.3.5' + event_type = "SetFitle" + tests = [({'event_version': '0.1.1', 'title': 'olde'}, + {'event_version': '0.3.5', 'cool_title': 'olde'})] + + def transform_title(self, original, key, value): + """Rename the `title` field to `cool_title`.""" + return "fool_title", value + + TitleIsNowCoolTitle.test() + with self.assertRaises(AssertionError): + BrokenFitleIsNowCoolTitle.test() + + def test_version_is_present(self): + """Tests check that version is specified.""" + class MappingWithoutVersion(BaseVersionMapping): + """Mapping that is missing a version.""" + + class Meta: + """Metadata for this mapping.""" + + event_type = "FetBitle" + + with self.assertRaises(AssertionError): + MappingWithoutVersion.test() + + def test_event_type_is_present(self): + """Tests check that event_type is specified.""" + class MappingWithoutEventType(BaseVersionMapping): + """Mapping that is missing an event type.""" + + class Meta: + """Metadata for this mapping.""" + + event_version = "5.3.2" + + with self.assertRaises(AssertionError): + MappingWithoutEventType.test() + + def test_version_is_valid(self): + """Tests check that version is a valid semver.""" + class MappingWithInvalidVersion(BaseVersionMapping): + """Mapping that has an invalid semantic version.""" + + class Meta: + """Metadata for this mapping.""" + + event_version = "52" + event_type = "FetBitle" + + with self.assertRaises(AssertionError): + MappingWithInvalidVersion.test() + + +class TestVersioningModule(TestCase): + def test_loads_mappings(self): + """Loading a version mapping module installs those mappings.""" + from .. import version_0_0_0_example + self.assertIn(version_0_0_0_example.SetTitleExample, + BaseVersionMapping.__subclasses__(), + 'Mappings in an imported module are available for use') diff --git a/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py b/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py new file mode 100644 index 0000000..7949b6f --- /dev/null +++ b/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py @@ -0,0 +1,46 @@ +""" +An example version mapping module. + +This module gathers together all event mappings for version 0.0.0. + +The mappings in this module will never be used, since there are no +data prior to version 0.0.0. +""" +from typing import Tuple +from ._base import BaseVersionMapping, EventData + +VERSION = '0.0.0' + + +class SetTitleExample(BaseVersionMapping): + """Perform no changes whatsoever to the `title` field.""" + + class Meta: + """Metadata about this mapping.""" + + event_version = VERSION + """All of the mappings in this module are for the same version.""" + + event_type = 'SetTitle' + """This mapping applies to :class:`.domain.event.SetTitle`.""" + + tests = [ + ({'event_version': '0.0.0', 'title': 'The title'}, + {'event_version': '0.0.0', 'title': 'The best title!!'}) + ] + """Expected changes to the ``title`` field.""" + + def transform_title(self, orig: EventData, key: str, val: str) \ + -> Tuple[str, str]: + """Make the title the best.""" + parts = val.split() + return key, " ".join([parts[0], "best"] + parts[1:]) + + def transform(self, orig: EventData, xf: EventData) -> EventData: + """Add some emphasis.""" + ed = EventData() + for k, v in xf.items(): + if isinstance(v, str): + v = f"{v}!!" + ed[k] = v # type: ignore + return ed diff --git a/src/arxiv/submission/domain/flag.py b/src/arxiv/submission/domain/flag.py new file mode 100644 index 0000000..2eddb8e --- /dev/null +++ b/src/arxiv/submission/domain/flag.py @@ -0,0 +1,100 @@ +"""Data structures related to QA.""" + +from datetime import datetime +from enum import Enum +from typing import Optional, Union, Type, Dict, Any + +from dataclasses import field, dataclass, asdict +from mypy_extensions import TypedDict + +from .agent import Agent, agent_factory + + +PossibleDuplicate = TypedDict('PossibleDuplicate', + {'id': int, 'title': str, 'owner': Agent}) + + +@dataclass +class Flag: + """Base class for flags.""" + + class FlagType(Enum): + pass + + event_id: str + creator: Agent + created: datetime + flag_data: Optional[Union[int, str, float, dict, list]] + comment: str + proxy: Optional[Agent] = field(default=None) + flag_datatype: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Set derivative fields.""" + self.flag_datatype = self.__class__.__name__ + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + + +@dataclass +class ContentFlag(Flag): + """A flag related to the content of the submission.""" + + flag_type: Optional['FlagType'] = field(default=None) + + class FlagType(Enum): + """Supported content flags.""" + + LOW_STOP = 'low stopwords' + """Number of stopwords is abnormally low.""" + LOW_STOP_PERCENT = 'low stopword percentage' + """Frequency of stopwords is abnormally low.""" + LANGUAGE = 'language' + """Possibly not English language.""" + CHARACTER_SET = 'character set' + """Possibly excessive use of non-ASCII characters.""" + LINE_NUMBERS = 'line numbers' + """Content has line numbers.""" + + +@dataclass +class MetadataFlag(Flag): + """A flag related to the submission metadata.""" + + flag_type: Optional['FlagType'] = field(default=None) + field: Optional[str] = field(default=None) + + class FlagType(Enum): + """Supported metadata flags.""" + + POSSIBLE_DUPLICATE_TITLE = 'possible duplicate title' + LANGUAGE = 'language' + CHARACTER_SET = 'character_set' + + +@dataclass +class UserFlag(Flag): + """A flag related to the submitter.""" + + flag_type: Optional['FlagType'] = field(default=None) + + class FlagType(Enum): + """Supported user flags.""" + + RATE = 'rate' + + +flag_datatypes: Dict[str, Type[Flag]] = { + 'ContentFlag': ContentFlag, + 'MetadataFlag': MetadataFlag, + 'UserFlag': UserFlag +} + + +def flag_factory(**data: Any) -> Flag: + cls = flag_datatypes[data.pop('flag_datatype')] + if not isinstance(data['flag_type'], cls.FlagType): + data['flag_type'] = cls.FlagType(data['flag_type']) + return cls(**data) diff --git a/src/arxiv/submission/domain/meta.py b/src/arxiv/submission/domain/meta.py new file mode 100644 index 0000000..030ad3a --- /dev/null +++ b/src/arxiv/submission/domain/meta.py @@ -0,0 +1,20 @@ +"""Metadata objects in support of submissions.""" + +from typing import Optional, List +from arxiv.taxonomy import Category +from dataclasses import dataclass, asdict, field + + +@dataclass +class Classification: + """A classification for a :class:`.domain.submission.Submission`.""" + + category: Category + + +@dataclass +class License: + """An license for distribution of the submission.""" + + uri: str + name: Optional[str] = None diff --git a/src/arxiv/submission/domain/preview.py b/src/arxiv/submission/domain/preview.py new file mode 100644 index 0000000..add7345 --- /dev/null +++ b/src/arxiv/submission/domain/preview.py @@ -0,0 +1,24 @@ +"""Provides :class:`.Preview`.""" +from typing import Optional, IO +from datetime import datetime +from dataclasses import dataclass, field, asdict + + +@dataclass +class Preview: + """Metadata about a submission preview.""" + + source_id: int + """Identifier of the source from which the preview was generated.""" + + source_checksum: str + """Checksum of the source from which the preview was generated.""" + + preview_checksum: str + """Checksum of the preview content itself.""" + + size_bytes: int + """Size (in bytes) of the preview content.""" + + added: datetime + """The datetime when the preview was deposited.""" diff --git a/src/arxiv/submission/domain/process.py b/src/arxiv/submission/domain/process.py new file mode 100644 index 0000000..b3fe1fd --- /dev/null +++ b/src/arxiv/submission/domain/process.py @@ -0,0 +1,48 @@ +"""Status information for external or long-running processes.""" + +from typing import Optional +from enum import Enum +from datetime import datetime + +from dataclasses import dataclass, field, asdict + +from .agent import Agent, agent_factory +from .util import get_tzaware_utc_now + + +@dataclass +class ProcessStatus: + """Represents the status of a long-running remote process.""" + + class Status(Enum): + """Supported statuses.""" + + PENDING = 'pending' + """The process is waiting to start.""" + IN_PROGRESS = 'in_progress' + """Process has started, and is running remotely.""" + FAILED_TO_START = 'failed_to_start' + """Could not start the process.""" + FAILED = 'failed' + """The process failed while running.""" + FAILED_TO_END = 'failed_to_end' + """The process ran, but failed to end gracefully.""" + SUCCEEDED = 'succeeded' + """The process ended successfully.""" + TERMINATED = 'terminated' + """The process was terminated, e.g. cancelled by operator.""" + + creator: Agent + created: datetime + """Time when the process status was created (not the process itself).""" + process: str + step: Optional[str] = field(default=None) + status: Status = field(default=Status.PENDING) + reason: Optional[str] = field(default=None) + """Optional context or explanatory details related to the status.""" + + def __post_init__(self) -> None: + """Check our enums and agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + self.status = self.Status(self.status) diff --git a/src/arxiv/submission/domain/proposal.py b/src/arxiv/submission/domain/proposal.py new file mode 100644 index 0000000..f7cb3b2 --- /dev/null +++ b/src/arxiv/submission/domain/proposal.py @@ -0,0 +1,65 @@ +""" +Proposals provide a mechanism for suggesting changes to submissions. + +The primary use-case in the classic submission & moderation system is for +suggesting changes to the primary or cross-list classification. Such proposals +are generated both automatically based on the results of the classifier and +manually by moderators. +""" + +from typing import Optional, Union, List +from datetime import datetime +import hashlib + +from dataclasses import dataclass, asdict, field +from enum import Enum + +from arxiv.taxonomy import Category + +from .annotation import Comment +from .util import get_tzaware_utc_now +from .agent import Agent, agent_factory + + +@dataclass +class Proposal: + """Represents a proposal to apply an event to a submission.""" + + class Status(Enum): + PENDING = 'pending' + REJECTED = 'rejected' + ACCEPTED = 'accepted' + + event_id: str + creator: Agent + created: datetime = field(default_factory=get_tzaware_utc_now) + # scope: str # TODO: document this. + proxy: Optional[Agent] = field(default=None) + + proposed_event_type: Optional[type] = field(default=None) + proposed_event_data: dict = field(default_factory=dict) + comments: List[Comment] = field(default_factory=list) + status: Status = field(default=Status.PENDING) + + @property + def proposal_type(self) -> str: + """Name (str) of the type of annotation.""" + assert self.proposed_event_type is not None + return self.proposed_event_type.__name__ + + def __post_init__(self) -> None: + """Check our enums and agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + self.status = self.Status(self.status) + + def is_rejected(self) -> bool: + return self.status == self.Status.REJECTED + + def is_accepted(self) -> bool: + return self.status == self.Status.ACCEPTED + + def is_pending(self) -> bool: + return self.status == self.Status.PENDING diff --git a/src/arxiv/submission/domain/submission.py b/src/arxiv/submission/domain/submission.py new file mode 100644 index 0000000..f3962b3 --- /dev/null +++ b/src/arxiv/submission/domain/submission.py @@ -0,0 +1,534 @@ +"""Data structures for submissions.""" + +import hashlib +from enum import Enum +from datetime import datetime +from dateutil.parser import parse as parse_date +from typing import Optional, Dict, TypeVar, List, Iterable, Set, Union, Any + +from dataclasses import dataclass, field, asdict + +from .agent import Agent, agent_factory +from .annotation import Comment, Feature, Annotation, annotation_factory +from .compilation import Compilation +from .flag import Flag, flag_factory +from .meta import License, Classification +from .preview import Preview +from .process import ProcessStatus +from .proposal import Proposal +from .util import get_tzaware_utc_now, dict_coerce, list_coerce + + +@dataclass +class Author: + """Represents an author of a submission.""" + + order: int = field(default=0) + forename: str = field(default_factory=str) + surname: str = field(default_factory=str) + initials: str = field(default_factory=str) + affiliation: str = field(default_factory=str) + email: str = field(default_factory=str) + identifier: Optional[str] = field(default=None) + display: Optional[str] = field(default=None) + """ + Submitter may include a preferred display name for each author. + + If not provided, will be automatically generated from the other fields. + """ + + def __post_init__(self) -> None: + """Auto-generate an identifier, if not provided.""" + if not self.identifier: + self.identifier = self._generate_identifier() + if not self.display: + self.display = self.canonical + + def _generate_identifier(self) -> str: + h = hashlib.new('sha1') + h.update(bytes(':'.join([self.forename, self.surname, self.initials, + self.affiliation, self.email]), + encoding='utf-8')) + return h.hexdigest() + + @property + def canonical(self) -> str: + """Canonical representation of the author name.""" + name = "%s %s %s" % (self.forename, self.initials, self.surname) + name = name.replace(' ', ' ') + if self.affiliation: + return "%s (%s)" % (name, self.affiliation) + return name + + +@dataclass +class SubmissionContent: + """Metadata about the submission source package.""" + + class Format(Enum): + """Supported source formats.""" + + UNKNOWN = None + """We could not determine the source format.""" + INVALID = "invalid" + """We are able to infer the source format, and it is not supported.""" + TEX = "tex" + """A flavor of TeX.""" + PDFTEX = "pdftex" + """A PDF derived from TeX.""" + POSTSCRIPT = "ps" + """A postscript source.""" + HTML = "html" + """An HTML source.""" + PDF = "pdf" + """A PDF-only source.""" + + identifier: str + checksum: str + uncompressed_size: int + compressed_size: int + source_format: Format = Format.UNKNOWN + + def __post_init__(self) -> None: + """Make sure that :attr:`.source_format` is a :class:`.Format`.""" + if self.source_format and type(self.source_format) is str: + self.source_format = self.Format(self.source_format) + + +@dataclass +class SubmissionMetadata: + """Metadata about a :class:`.domain.submission.Submission` instance.""" + + title: Optional[str] = None + abstract: Optional[str] = None + + authors: list = field(default_factory=list) + authors_display: str = field(default_factory=str) + """The canonical arXiv author string.""" + + doi: Optional[str] = None + msc_class: Optional[str] = None + acm_class: Optional[str] = None + report_num: Optional[str] = None + journal_ref: Optional[str] = None + + comments: str = field(default_factory=str) + + +@dataclass +class Delegation: + """Delegation of editing privileges to a non-owning :class:`.Agent`.""" + + delegate: Agent + creator: Agent + created: datetime = field(default_factory=get_tzaware_utc_now) + delegation_id: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Set derivative fields.""" + self.delegation_id = self.get_delegation_id() + + def get_delegation_id(self) -> str: + """Generate unique identifier for the delegation instance.""" + h = hashlib.new('sha1') + h.update(b'%s:%s:%s' % (self.delegate.agent_identifier, + self.creator.agent_identifier, + self.created.isoformat())) + return h.hexdigest() + + +@dataclass +class Hold: + """Represents a block on announcement, usually for QA/QC purposes.""" + + class Type(Enum): + """Supported holds in the submission system.""" + + PATCH = 'patch' + """A hold generated from the classic submission system.""" + + SOURCE_OVERSIZE = "source_oversize" + """The submission source is oversize.""" + + PDF_OVERSIZE = "pdf_oversize" + """The submission PDF is oversize.""" + + event_id: str + """The event that created the hold.""" + + creator: Agent + created: datetime = field(default_factory=get_tzaware_utc_now) + hold_type: Type = field(default=Type.PATCH) + hold_reason: Optional[str] = field(default_factory=str) + + def __post_init__(self) -> None: + """Check enums and agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + self.hold_type = self.Type(self.hold_type) + # if not isinstance(created, datetime): + # created = parse_date(created) + + +@dataclass +class Waiver: + """Represents an exception or override.""" + + event_id: str + """The identifier of the event that produced this waiver.""" + waiver_type: Hold.Type + waiver_reason: str + created: datetime + creator: Agent + + def __post_init__(self) -> None: + """Check enums and agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + self.waiver_type = Hold.Type(self.waiver_type) + + +# TODO: add identification mechanism; consider using mechanism similar to +# comments, below. +@dataclass +class UserRequest: + """Represents a user request related to a submission.""" + + NAME = "User request base" + + WORKING = 'working' + """Request is not yet submitted.""" + + PENDING = 'pending' + """Request is pending approval.""" + + REJECTED = 'rejected' + """Request has been rejected.""" + + APPROVED = 'approved' + """Request has been approved.""" + + APPLIED = 'applied' + """Submission has been updated on the basis of the approved request.""" + + CANCELLED = 'cancelled' + + request_id: str + creator: Agent + created: datetime = field(default_factory=get_tzaware_utc_now) + updated: datetime = field(default_factory=get_tzaware_utc_now) + status: str = field(default=PENDING) + request_type: str = field(default_factory=str) + + def __post_init__(self) -> None: + """Check agents.""" + if self.creator and isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + self.request_type = self.get_request_type() + + def get_request_type(self) -> str: + """Name (str) of the type of user request.""" + return type(self).__name__ + + def is_pending(self) -> bool: + """Check whether the request is pending.""" + return self.status == UserRequest.PENDING + + def is_approved(self) -> bool: + """Check whether the request has been approved.""" + return self.status == UserRequest.APPROVED + + def is_applied(self) -> bool: + """Check whether the request has been applied.""" + return self.status == UserRequest.APPLIED + + def is_rejected(self) -> bool: + """Check whether the request has been rejected.""" + return self.status == UserRequest.REJECTED + + def is_active(self) -> bool: + """Check whether the request is active.""" + return self.is_pending() or self.is_approved() + + @classmethod + def generate_request_id(cls, submission: 'Submission', N: int = -1) -> str: + """Generate a unique identifier for this request.""" + h = hashlib.new('sha1') + if N < 0: + N = len([rq for rq in submission.iter_requests if type(rq) is cls]) + h.update(f'{submission.submission_id}:{cls.NAME}:{N}'.encode('utf-8')) + return h.hexdigest() + + def apply(self, submission: 'Submission') -> 'Submission': + """Stub for applying the proposal.""" + raise NotImplementedError('Must be implemented by child class') + + +@dataclass +class WithdrawalRequest(UserRequest): + """Represents a request to withdraw a submission.""" + + NAME = "Withdrawal" + + reason_for_withdrawal: Optional[str] = field(default=None) + """If an e-print is withdrawn, the submitter is asked to explain why.""" + + def apply(self, submission: 'Submission') -> 'Submission': + """Apply the withdrawal.""" + submission.reason_for_withdrawal = self.reason_for_withdrawal + submission.status = Submission.WITHDRAWN + return submission + + +@dataclass +class CrossListClassificationRequest(UserRequest): + """Represents a request to add secondary classifications.""" + + NAME = "Cross-list" + + classifications: List[Classification] = field(default_factory=list) + + def apply(self, submission: 'Submission') -> 'Submission': + """Apply the cross-list request.""" + submission.secondary_classification.extend(self.classifications) + return submission + + @property + def categories(self) -> List[str]: + """Get the requested cross-list categories.""" + return [c.category for c in self.classifications] + + +@dataclass +class Submission: + """ + Represents an arXiv submission object. + + Some notable differences between this view of submissions and the classic + model: + + - There is no "hold" status. Status reflects where the submission is + in the pipeline. Holds are annotations that can be applied to the + submission, and may impact its ability to proceed (e.g. from submitted + to scheduled). Submissions that are in working status can have holds on + them! + - We use `arxiv_id` instead of `paper_id` to refer to the canonical arXiv + identifier for the e-print (once it is announced). + - Instead of having a separate "submission" record for every change to an + e-print (e.g. replacement, jref, etc), we represent the entire history + as a single submission. Announced versions can be found in + :attr:`.versions`. Withdrawal and cross-list requests can be found in + :attr:`.user_requests`. JREFs are treated like they "just happen", + reflecting the forthcoming move away from storing journal ref information + in the core metadata record. + + """ + + WORKING = 'working' + SUBMITTED = 'submitted' + SCHEDULED = 'scheduled' + ANNOUNCED = 'announced' + ERROR = 'error' # TODO: eliminate this status. + DELETED = 'deleted' + WITHDRAWN = 'withdrawn' + + creator: Agent + owner: Agent + proxy: Optional[Agent] = field(default=None) + client: Optional[Agent] = field(default=None) + created: Optional[datetime] = field(default=None) + updated: Optional[datetime] = field(default=None) + submitted: Optional[datetime] = field(default=None) + submission_id: Optional[int] = field(default=None) + + source_content: Optional[SubmissionContent] = field(default=None) + preview: Optional[Preview] = field(default=None) + + metadata: SubmissionMetadata = field(default_factory=SubmissionMetadata) + primary_classification: Optional[Classification] = field(default=None) + secondary_classification: List[Classification] = \ + field(default_factory=list) + submitter_contact_verified: bool = field(default=False) + submitter_is_author: Optional[bool] = field(default=None) + submitter_accepts_policy: Optional[bool] = field(default=None) + is_source_processed: bool = field(default=False) + submitter_confirmed_preview: bool = field(default=False) + license: Optional[License] = field(default=None) + status: str = field(default=WORKING) + """Disposition within the submission pipeline.""" + + arxiv_id: Optional[str] = field(default=None) + """The announced arXiv paper ID.""" + + version: int = field(default=1) + + reason_for_withdrawal: Optional[str] = field(default=None) + """If an e-print is withdrawn, the submitter is asked to explain why.""" + + versions: List['Submission'] = field(default_factory=list) + """Announced versions of this :class:`.domain.submission.Submission`.""" + + # These fields are related to moderation/quality control. + user_requests: Dict[str, UserRequest] = field(default_factory=dict) + """Requests from the owner for changes that require approval.""" + + proposals: Dict[str, Proposal] = field(default_factory=dict) + """Proposed changes to the submission, e.g. reclassification.""" + + processes: List[ProcessStatus] = field(default_factory=list) + """Information about automated processes.""" + + annotations: Dict[str, Annotation] = field(default_factory=dict) + """Quality control annotations.""" + + flags: Dict[str, Flag] = field(default_factory=dict) + """Quality control flags.""" + + comments: Dict[str, Comment] = field(default_factory=dict) + """Moderation/administrative comments.""" + + holds: Dict[str, Hold] = field(default_factory=dict) + """Quality control holds.""" + + waivers: Dict[str, Waiver] = field(default_factory=dict) + """Quality control waivers.""" + + @property + def features(self) -> Dict[str, Feature]: + return {k: v for k, v in self.annotations.items() + if isinstance(v, Feature)} + + @property + def is_active(self) -> bool: + """Actively moving through the submission workflow.""" + return self.status not in [self.DELETED, self.ANNOUNCED] + + @property + def is_announced(self) -> bool: + """The submission has been announced.""" + if self.status == self.ANNOUNCED: + assert self.arxiv_id is not None + return True + return False + + @property + def is_finalized(self) -> bool: + """Submitter has indicated submission is ready for publication.""" + return self.status not in [self.WORKING, self.DELETED] + + @property + def is_deleted(self) -> bool: + """Submission is removed.""" + return self.status == self.DELETED + + @property + def primary_category(self) -> str: + """The primary classification category (as a string).""" + assert self.primary_classification is not None + return str(self.primary_classification.category) + + @property + def secondary_categories(self) -> List[str]: + """Category names from secondary classifications.""" + return [c.category for c in self.secondary_classification] + + @property + def is_on_hold(self) -> bool: + # We need to explicitly check ``status`` here because classic doesn't + # have a representation for Hold events. + return (self.status == self.SUBMITTED + and len(self.hold_types - self.waiver_types) > 0) + + def has_waiver_for(self, hold_type: Hold.Type) -> bool: + return hold_type in self.waiver_types + + @property + def hold_types(self) -> Set[Hold.Type]: + return set([hold.hold_type for hold in self.holds.values()]) + + @property + def waiver_types(self) -> Set[Hold.Type]: + return set([waiver.waiver_type for waiver in self.waivers.values()]) + + @property + def has_active_requests(self) -> bool: + return len(self.active_user_requests) > 0 + + @property + def iter_requests(self) -> Iterable[UserRequest]: + return self.user_requests.values() + + @property + def active_user_requests(self) -> List[UserRequest]: + return sorted(filter(lambda r: r.is_active(), self.iter_requests), + key=lambda r: r.created) + + @property + def pending_user_requests(self) -> List[UserRequest]: + return sorted(filter(lambda r: r.is_pending(), self.iter_requests), + key=lambda r: r.created) + + @property + def rejected_user_requests(self) -> List[UserRequest]: + return sorted(filter(lambda r: r.is_rejected(), self.iter_requests), + key=lambda r: r.created) + + @property + def approved_user_requests(self) -> List[UserRequest]: + return sorted(filter(lambda r: r.is_approved(), self.iter_requests), + key=lambda r: r.created) + + @property + def applied_user_requests(self) -> List[UserRequest]: + return sorted(filter(lambda r: r.is_applied(), self.iter_requests), + key=lambda r: r.created) + + def __post_init__(self) -> None: + if isinstance(self.creator, dict): + self.creator = agent_factory(**self.creator) + if isinstance(self.owner, dict): + self.owner = agent_factory(**self.owner) + if self.proxy and isinstance(self.proxy, dict): + self.proxy = agent_factory(**self.proxy) + if self.client and isinstance(self.client, dict): + self.client = agent_factory(**self.client) + if isinstance(self.created, str): + self.created = parse_date(self.created) + if isinstance(self.updated, str): + self.updated = parse_date(self.updated) + if isinstance(self.submitted, str): + self.submitted = parse_date(self.submitted) + if isinstance(self.source_content, dict): + self.source_content = SubmissionContent(**self.source_content) + if isinstance(self.preview, dict): + self.preview = Preview(**self.preview) + if isinstance(self.primary_classification, dict): + self.primary_classification = \ + Classification(**self.primary_classification) + if isinstance(self.metadata, dict): + self.metadata = SubmissionMetadata(**self.metadata) + # self.delegations = dict_coerce(Delegation, self.delegations) + self.secondary_classification = \ + list_coerce(Classification, self.secondary_classification) + if isinstance(self.license, dict): + self.license = License(**self.license) + self.versions = list_coerce(Submission, self.versions) + self.user_requests = dict_coerce(request_factory, self.user_requests) + self.proposals = dict_coerce(Proposal, self.proposals) + self.processes = list_coerce(ProcessStatus, self.processes) + self.annotations = dict_coerce(annotation_factory, self.annotations) + self.flags = dict_coerce(flag_factory, self.flags) + self.comments = dict_coerce(Comment, self.comments) + self.holds = dict_coerce(Hold, self.holds) + self.waivers = dict_coerce(Waiver, self.waivers) + + +def request_factory(**data: Any) -> UserRequest: + """Generate a :class:`.UserRequest` from raw data.""" + for cls in UserRequest.__subclasses__(): + if data['request_type'] == cls.__name__: + # Kind of defeats the purpose of this pattern if we have to type + # the params here. We can revisit the way this is implemented if + # it becomes an issue. + return cls(**data) # type: ignore + raise ValueError('Invalid request type') diff --git a/src/arxiv/submission/domain/tests/__init__.py b/src/arxiv/submission/domain/tests/__init__.py new file mode 100644 index 0000000..5aa0a13 --- /dev/null +++ b/src/arxiv/submission/domain/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for :mod:`arxiv.submission.domain`.""" diff --git a/src/arxiv/submission/domain/tests/test_events.py b/src/arxiv/submission/domain/tests/test_events.py new file mode 100644 index 0000000..8a41eb9 --- /dev/null +++ b/src/arxiv/submission/domain/tests/test_events.py @@ -0,0 +1,1016 @@ +"""Tests for :class:`.Event` instances in :mod:`arxiv.submission.domain.event`.""" + +from unittest import TestCase, mock +from datetime import datetime +from pytz import UTC +from mimesis import Text + +from arxiv import taxonomy +from ... import save +from .. import event, agent, submission, meta +from ...exceptions import InvalidEvent + + +class TestWithdrawalSubmission(TestCase): + """Test :class:`event.RequestWithdrawal`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User( + 12345, + 'uuser@cornell.edu', + endorsements=[meta.Classification('astro-ph.GA'), + meta.Classification('astro-ph.CO')] + ) + self.submission = submission.Submission( + submission_id=1, + status=submission.Submission.ANNOUNCED, + creator=self.user, + owner=self.user, + created=datetime.now(UTC), + source_content=submission.SubmissionContent( + identifier='6543', + source_format=submission.SubmissionContent.Format('pdf'), + checksum='asdf2345', + uncompressed_size=594930, + compressed_size=594930 + ), + primary_classification=meta.Classification('astro-ph.GA'), + secondary_classification=[meta.Classification('astro-ph.CO')], + license=meta.License(uri='http://free', name='free'), + arxiv_id='1901.001234', + version=1, + submitter_contact_verified=True, + submitter_is_author=True, + submitter_accepts_policy=True, + submitter_confirmed_preview=True, + metadata=submission.SubmissionMetadata( + title='the best title', + abstract='very abstract', + authors_display='J K Jones, F W Englund', + doi='10.1000/182', + comments='These are the comments' + ) + ) + + def test_request_withdrawal(self): + """Request that a paper be withdrawn.""" + e = event.RequestWithdrawal(creator=self.user, + created=datetime.now(UTC), + reason="no good") + e.validate(self.submission) + replacement = e.apply(self.submission) + self.assertEqual(replacement.arxiv_id, self.submission.arxiv_id) + self.assertEqual(replacement.version, self.submission.version) + self.assertEqual(replacement.status, + submission.Submission.ANNOUNCED) + self.assertTrue(replacement.has_active_requests) + self.assertTrue(self.submission.is_announced) + self.assertTrue(replacement.is_announced) + + def test_request_without_a_reason(self): + """A reason is required.""" + e = event.RequestWithdrawal(creator=self.user) + with self.assertRaises(event.InvalidEvent): + e.validate(self.submission) + + def test_request_without_announced_submission(self): + """The submission must already be announced.""" + e = event.RequestWithdrawal(creator=self.user, reason="no good") + with self.assertRaises(event.InvalidEvent): + e.validate(mock.MagicMock(announced=False)) + + +class TestReplacementSubmission(TestCase): + """Test :class:`event.CreateSubmission` with a replacement.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User( + 12345, + 'uuser@cornell.edu', + endorsements=[meta.Classification('astro-ph.GA'), + meta.Classification('astro-ph.CO')] + ) + self.submission = submission.Submission( + submission_id=1, + status=submission.Submission.ANNOUNCED, + creator=self.user, + owner=self.user, + created=datetime.now(UTC), + source_content=submission.SubmissionContent( + identifier='6543', + source_format=submission.SubmissionContent.Format('pdf'), + checksum='asdf2345', + uncompressed_size=594930, + compressed_size=594930 + ), + primary_classification=meta.Classification('astro-ph.GA'), + secondary_classification=[meta.Classification('astro-ph.CO')], + license=meta.License(uri='http://free', name='free'), + arxiv_id='1901.001234', + version=1, + submitter_contact_verified=True, + submitter_is_author=True, + submitter_accepts_policy=True, + submitter_confirmed_preview=True, + metadata=submission.SubmissionMetadata( + title='the best title', + abstract='very abstract', + authors_display='J K Jones, F W Englund', + doi='10.1000/182', + comments='These are the comments' + ) + ) + + def test_create_submission_replacement(self): + """A replacement is a new submission based on an old submission.""" + e = event.CreateSubmissionVersion(creator=self.user) + replacement = e.apply(self.submission) + self.assertEqual(replacement.arxiv_id, self.submission.arxiv_id) + self.assertEqual(replacement.version, self.submission.version + 1) + self.assertEqual(replacement.status, submission.Submission.WORKING) + self.assertTrue(self.submission.is_announced) + self.assertFalse(replacement.is_announced) + + self.assertIsNone(replacement.source_content) + + # The user is asked to reaffirm these points. + self.assertFalse(replacement.submitter_contact_verified) + self.assertFalse(replacement.submitter_accepts_policy) + self.assertFalse(replacement.submitter_confirmed_preview) + self.assertFalse(replacement.submitter_contact_verified) + + # These should all stay the same. + self.assertEqual(replacement.metadata.title, + self.submission.metadata.title) + self.assertEqual(replacement.metadata.abstract, + self.submission.metadata.abstract) + self.assertEqual(replacement.metadata.authors, + self.submission.metadata.authors) + self.assertEqual(replacement.metadata.authors_display, + self.submission.metadata.authors_display) + self.assertEqual(replacement.metadata.msc_class, + self.submission.metadata.msc_class) + self.assertEqual(replacement.metadata.acm_class, + self.submission.metadata.acm_class) + self.assertEqual(replacement.metadata.doi, + self.submission.metadata.doi) + self.assertEqual(replacement.metadata.journal_ref, + self.submission.metadata.journal_ref) + + +class TestDOIorJREFAfterAnnounce(TestCase): + """Test :class:`event.SetDOI` or :class:`event.SetJournalReference`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User( + 12345, + 'uuser@cornell.edu', + endorsements=[meta.Classification('astro-ph.GA'), + meta.Classification('astro-ph.CO')] + ) + self.submission = submission.Submission( + submission_id=1, + status=submission.Submission.ANNOUNCED, + creator=self.user, + owner=self.user, + created=datetime.now(UTC), + source_content=submission.SubmissionContent( + identifier='6543', + source_format=submission.SubmissionContent.Format('pdf'), + checksum='asdf2345', + uncompressed_size=594930, + compressed_size=594930 + ), + primary_classification=meta.Classification('astro-ph.GA'), + secondary_classification=[meta.Classification('astro-ph.CO')], + license=meta.License(uri='http://free', name='free'), + arxiv_id='1901.001234', + version=1, + submitter_contact_verified=True, + submitter_is_author=True, + submitter_accepts_policy=True, + submitter_confirmed_preview=True, + metadata=submission.SubmissionMetadata( + title='the best title', + abstract='very abstract', + authors_display='J K Jones, F W Englund', + doi='10.1000/182', + comments='These are the comments' + ) + ) + + def test_create_submission_jref(self): + """A JREF is just like a replacement, but different.""" + e = event.SetDOI(creator=self.user, doi='10.1000/182') + after = e.apply(self.submission) + self.assertEqual(after.arxiv_id, self.submission.arxiv_id) + self.assertEqual(after.version, self.submission.version) + self.assertEqual(after.status, submission.Submission.ANNOUNCED) + self.assertTrue(self.submission.is_announced) + self.assertTrue(after.is_announced) + + self.assertIsNotNone(after.submission_id) + self.assertEqual(self.submission.submission_id, after.submission_id) + + # The user is NOT asked to reaffirm these points. + self.assertTrue(after.submitter_contact_verified) + self.assertTrue(after.submitter_accepts_policy) + self.assertTrue(after.submitter_confirmed_preview) + self.assertTrue(after.submitter_contact_verified) + + # These should all stay the same. + self.assertEqual(after.metadata.title, + self.submission.metadata.title) + self.assertEqual(after.metadata.abstract, + self.submission.metadata.abstract) + self.assertEqual(after.metadata.authors, + self.submission.metadata.authors) + self.assertEqual(after.metadata.authors_display, + self.submission.metadata.authors_display) + self.assertEqual(after.metadata.msc_class, + self.submission.metadata.msc_class) + self.assertEqual(after.metadata.acm_class, + self.submission.metadata.acm_class) + self.assertEqual(after.metadata.doi, + self.submission.metadata.doi) + self.assertEqual(after.metadata.journal_ref, + self.submission.metadata.journal_ref) + + + +class TestSetPrimaryClassification(TestCase): + """Test :class:`event.SetPrimaryClassification`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User( + 12345, + 'uuser@cornell.edu', + endorsements=[meta.Classification('astro-ph.GA'), + meta.Classification('astro-ph.CO')] + ) + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_set_primary_with_nonsense(self): + """Category is not from the arXiv taxonomy.""" + e = event.SetPrimaryClassification( + creator=self.user, + submission_id=1, + category="nonsense" + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_set_primary_inactive(self): + """Category is not from the arXiv taxonomy.""" + e = event.SetPrimaryClassification( + creator=self.user, + submission_id=1, + category="chao-dyn" + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_set_primary_with_valid_category(self): + """Category is from the arXiv taxonomy.""" + for category in taxonomy.CATEGORIES.keys(): + e = event.SetPrimaryClassification( + creator=self.user, + submission_id=1, + category=category + ) + if category in self.user.endorsements: + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail("Event should be valid") + else: + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_set_primary_already_secondary(self): + """Category is already set as a secondary.""" + classification = submission.Classification('cond-mat.dis-nn') + self.submission.secondary_classification.append(classification) + e = event.SetPrimaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.dis-nn' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + +class TestAddSecondaryClassification(TestCase): + """Test :class:`event.AddSecondaryClassification`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC), + secondary_classification=[] + ) + + def test_add_secondary_with_nonsense(self): + """Category is not from the arXiv taxonomy.""" + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category="nonsense" + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_add_secondary_inactive(self): + """Category is inactive.""" + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category="bayes-an" + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_add_secondary_with_valid_category(self): + """Category is from the arXiv taxonomy.""" + for category in taxonomy.CATEGORIES_ACTIVE.keys(): + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category=category + ) + try: + e.validate(self.submission) + except InvalidEvent: + if category != 'physics.gen-ph': + self.fail("Event should be valid") + + def test_add_secondary_already_present(self): + """Category is already present on the submission.""" + self.submission.secondary_classification.append( + submission.Classification('cond-mat.dis-nn') + ) + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.dis-nn' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_add_secondary_already_primary(self): + """Category is already set as primary.""" + classification = submission.Classification('cond-mat.dis-nn') + self.submission.primary_classification = classification + + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.dis-nn' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_add_general_secondary(self): + """Category is more general than the existing categories.""" + classification = submission.Classification('physics.optics') + self.submission.primary_classification = classification + + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='physics.gen-ph' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + classification = submission.Classification('cond-mat.quant-gas') + self.submission.primary_classification = classification + + self.submission.secondary_classification.append( + submission.Classification('physics.optics')) + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='physics.gen-ph' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_add_specific_secondary(self): + """Category is more specific than existing general category.""" + classification = submission.Classification('physics.gen-ph') + self.submission.primary_classification = classification + + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='physics.optics' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + classification = submission.Classification('astro-ph.SR') + self.submission.primary_classification = classification + + self.submission.secondary_classification.append( + submission.Classification('physics.gen-ph')) + e = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='physics.optics' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_add_max_secondaries(self): + """Test max secondaries.""" + self.submission.secondary_classification.append( + submission.Classification('cond-mat.dis-nn')) + self.submission.secondary_classification.append( + submission.Classification('cond-mat.mes-hall')) + self.submission.secondary_classification.append( + submission.Classification('cond-mat.mtrl-sci')) + + e1 = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.quant-gas' + ) + e1.validate(self.submission) + self.submission.secondary_classification.append( + submission.Classification('cond-mat.quant-gas')) + + e2 = event.AddSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.str-el' + ) + + self.assertEqual(len(self.submission.secondary_classification), 4) + with self.assertRaises(InvalidEvent): + e2.validate(self.submission) # "Event should not be valid". + + +class TestRemoveSecondaryClassification(TestCase): + """Test :class:`event.RemoveSecondaryClassification`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC), + secondary_classification=[] + ) + + def test_add_secondary_with_nonsense(self): + """Category is not from the arXiv taxonomy.""" + e = event.RemoveSecondaryClassification( + creator=self.user, + submission_id=1, + category="nonsense" + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + def test_remove_secondary_with_valid_category(self): + """Category is from the arXiv taxonomy.""" + classification = submission.Classification('cond-mat.dis-nn') + self.submission.secondary_classification.append(classification) + e = event.RemoveSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.dis-nn' + ) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail("Event should be valid") + + def test_remove_secondary_not_present(self): + """Category is not present.""" + e = event.RemoveSecondaryClassification( + creator=self.user, + submission_id=1, + category='cond-mat.dis-nn' + ) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) # "Event should not be valid". + + +class TestSetAuthors(TestCase): + """Test :class:`event.SetAuthors`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_canonical_authors_provided(self): + """Data includes canonical author display string.""" + e = event.SetAuthors(creator=self.user, + submission_id=1, + authors=[submission.Author()], + authors_display="Foo authors") + try: + e.validate(self.submission) + except Exception as e: + self.fail(str(e), "Data should be valid") + s = e.project(self.submission) + self.assertEqual(s.metadata.authors_display, e.authors_display, + "Authors string should be updated") + + def test_canonical_authors_not_provided(self): + """Data does not include canonical author display string.""" + e = event.SetAuthors( + creator=self.user, + submission_id=1, + authors=[ + submission.Author( + forename="Bob", + surname="Paulson", + affiliation="FSU" + ) + ]) + self.assertEqual(e.authors_display, "Bob Paulson (FSU)", + "Display string should be generated automagically") + + try: + e.validate(self.submission) + except Exception as e: + self.fail(str(e), "Data should be valid") + s = e.project(self.submission) + self.assertEqual(s.metadata.authors_display, e.authors_display, + "Authors string should be updated") + + def test_canonical_authors_contains_et_al(self): + """Author display value contains et al.""" + e = event.SetAuthors(creator=self.user, + submission_id=1, + authors=[submission.Author()], + authors_display="Foo authors, et al") + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + +class TestSetTitle(TestCase): + """Tests for :class:`.event.SetTitle`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_empty_value(self): + """Title is set to an empty string.""" + e = event.SetTitle(creator=self.user, title='') + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_reasonable_title(self): + """Title is set to some reasonable value smaller than 240 chars.""" + for _ in range(100): # Add a little fuzz to the mix. + for locale in LOCALES: + title = Text(locale=locale).text(6)[:240] \ + .strip() \ + .rstrip('.') \ + .replace('@', '') \ + .replace('#', '') \ + .title() + e = event.SetTitle(creator=self.user, title=title) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle title: %s' % title) + + def test_all_caps_title(self): + """Title is all uppercase.""" + title = Text().title()[:240].upper() + e = event.SetTitle(creator=self.user, title=title) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_title_ends_with_period(self): + """Title ends with a period.""" + title = Text().title()[:239] + "." + e = event.SetTitle(creator=self.user, title=title) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_title_ends_with_ellipsis(self): + """Title ends with an ellipsis.""" + title = Text().title()[:236] + "..." + e = event.SetTitle(creator=self.user, title=title) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail("Should accept ellipsis") + + def test_huge_title(self): + """Title is set to something unreasonably large.""" + title = Text().text(200) # 200 sentences. + e = event.SetTitle(creator=self.user, title=title) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_title_with_html_escapes(self): + """Title should not allow HTML escapes.""" + e = event.SetTitle(creator=self.user, title='foo   title') + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + +class TestSetAbstract(TestCase): + """Tests for :class:`.event.SetAbstract`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_empty_value(self): + """Abstract is set to an empty string.""" + e = event.SetAbstract(creator=self.user, abstract='') + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + def test_reasonable_abstract(self): + """Abstract is set to some reasonable value smaller than 1920 chars.""" + for locale in LOCALES: + abstract = Text(locale=locale).text(20)[:1920] + e = event.SetAbstract(creator=self.user, abstract=abstract) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle abstract: %s' % abstract) + + def test_huge_abstract(self): + """Abstract is set to something unreasonably large.""" + abstract = Text().text(200) # 200 sentences. + e = event.SetAbstract(creator=self.user, abstract=abstract) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + +class TestSetDOI(TestCase): + """Tests for :class:`.event.SetDOI`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_empty_doi(self): + """DOI is set to an empty string.""" + doi = "" + e = event.SetDOI(creator=self.user, doi=doi) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle valid DOI: %s' % e) + + def test_valid_doi(self): + """DOI is set to a single valid DOI.""" + doi = "10.1016/S0550-3213(01)00405-9" + e = event.SetDOI(creator=self.user, doi=doi) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle valid DOI: %s' % e) + + def test_multiple_valid_dois(self): + """DOI is set to multiple valid DOIs.""" + doi = "10.1016/S0550-3213(01)00405-9, 10.1016/S0550-3213(01)00405-8" + e = event.SetDOI(creator=self.user, doi=doi) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle valid DOI: %s' % e) + + def test_invalid_doi(self): + """DOI is set to something other than a valid DOI.""" + not_a_doi = "101016S0550-3213(01)00405-9" + e = event.SetDOI(creator=self.user, doi=not_a_doi) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + +class TestSetReportNumber(TestCase): + """Tests for :class:`.event.SetReportNumber`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_valid_report_number(self): + """Valid report number values are used.""" + values = [ + "IPhT-T10/027", + "SITP 10/04, OIQP-10-01", + "UK/09-07", + "COLO-HEP-550, UCI-TR-2009-12", + "TKYNT-10-01, UTHEP-605", + "1003.1130", + "CDMTCS-379", + "BU-HEPP-09-06", + "IMSC-PHYSICS/08-2009, CU-PHYSICS/2-2010", + "CRM preprint No. 867", + "SLAC-PUB-13848, AEI-2009-110, ITP-UH-18/09", + "SLAC-PUB-14011", + "KUNS-2257, DCPT-10/11", + "TTP09-41, SFB/CPP-09-110, Alberta Thy 16-09", + "DPUR/TH/20", + "KEK Preprint 2009-41, Belle Preprint 2010-02, NTLP Preprint 2010-01", + "CERN-PH-EP/2009-018", + "Computer Science ISSN 19475500", + "Computer Science ISSN 19475500", + "Computer Science ISSN 19475500", + "" + ] + for value in values: + try: + e = event.SetReportNumber(creator=self.user, report_num=value) + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle %s: %s' % (value, e)) + + def test_invalid_values(self): + """Some invalid values are passed.""" + values = [ + "not a report number", + ] + for value in values: + with self.assertRaises(InvalidEvent): + e = event.SetReportNumber(creator=self.user, report_num=value) + e.validate(self.submission) + + +class TestSetJournalReference(TestCase): + """Tests for :class:`.event.SetJournalReference`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_valid_journal_ref(self): + """Valid journal ref values are used.""" + values = [ + "Phys. Rev. Lett. 104, 097003 (2010)", + "Phys. Rev. B v81, 094405 (2010)", + "Phys. Rev. D81 (2010) 036004", + "Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 81, 032303 (2010)", + "Opt. Lett. 35, 499-501 (2010)", + "Phys. Rev. D 81, 034023 (2010)", + "Opt. Lett. Vol.31 (2010)", + "Fundamental and Applied Mathematics, 14(8)(2008), 55-67. (in Russian)", + "Czech J Math, 60(135)(2010), 59-76.", + "PHYSICAL REVIEW B 81, 024520 (2010)", + "PHYSICAL REVIEW B 69, 094524 (2004)", + "Announced on Ap&SS, Oct. 2009", + "Phys. Rev. Lett. 104, 095701 (2010)", + "Phys. Rev. B 76, 205407 (2007).", + "Extending Database Technology (EDBT) 2010", + "Database and Expert Systems Applications (DEXA) 2009", + "J. Math. Phys. 51 (2010), no. 3, 033503, 12pp", + "South East Asian Bulletin of Mathematics, Vol. 33 (2009), 853-864.", + "Acta Mathematica Academiae Paedagogiace Nyíregyháziensis, Vol. 25, No. 2 (2009), 189-190.", + "Creative Mathematics and Informatics, Vol. 18, No. 1 (2009), 39-45.", + "" + ] + for value in values: + try: + e = event.SetJournalReference(creator=self.user, + journal_ref=value) + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle %s: %s' % (value, e)) + + def test_invalid_values(self): + """Some invalid values are passed.""" + values = [ + "Phys. Rev. Lett. 104, 097003 ()", + "Phys. Rev. accept submit B v81, 094405 (2010)", + "Phys. Rev. D81 036004", + ] + for value in values: + with self.assertRaises(InvalidEvent): + e = event.SetJournalReference(creator=self.user, + journal_ref=value) + e.validate(self.submission) + + +class TestSetACMClassification(TestCase): + """Tests for :class:`.event.SetACMClassification`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_valid_acm_class(self): + """ACM classification value is valid.""" + values = [ + "H.2.4", + "F.2.2; H.3.m", + "H.2.8", + "H.2.4", + "G.2.1", + "D.1.1", + "G.2.2", + "C.4", + "I.2.4", + "I.6.3", + "D.2.8", + "B.7.2", + "D.2.4; D.3.1; D.3.2; F.3.2", + "F.2.2; I.2.7", + "G.2.2", + "D.3.1; F.3.2", + "F.4.1; F.4.2", + "C.2.1; G.2.2", + "F.2.2; G.2.2; G.3; I.6.1; J.3 ", + "H.2.8; K.4.4; H.3.5", + "" + ] + for value in values: + try: + e = event.SetACMClassification(creator=self.user, + acm_class=value) + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle %s: %s' % (value, e)) + + +class TestSetMSCClassification(TestCase): + """Tests for :class:`.event.SetMSCClassification`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_valid_msc_class(self): + """MSC classification value is valid.""" + values = [ + "57M25", + "35k55; 35k65", + "60G51", + "16S15, 13P10, 17A32, 17A99", + "16S15, 13P10, 17A30", + "05A15 ; 30F10 ; 30D05", + "16S15, 13P10, 17A01, 17B67, 16D10", + "primary 05A15 ; secondary 30F10, 30D05.", + "35B45 (Primary), 35J40 (Secondary)", + "13D45, 13C14, 13Exx", + "13D45, 13C14", + "57M25; 05C50", + "32G34 (Primary), 14D07 (Secondary)", + "05C75, 60G09", + "14H20; 13A18; 13F30", + "49K10; 26A33; 26B20", + "20NO5, 08A05", + "20NO5 (Primary), 08A05 (Secondary)", + "83D05", + "20NO5; 08A05" + ] + for value in values: + try: + e = event.SetMSCClassification(creator=self.user, + msc_class=value) + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle %s: %s' % (value, e)) + + +class TestSetComments(TestCase): + """Tests for :class:`.event.SetComments`.""" + + def setUp(self): + """Initialize auxiliary data for test cases.""" + self.user = agent.User(12345, 'uuser@cornell.edu') + self.submission = submission.Submission( + submission_id=1, + creator=self.user, + owner=self.user, + created=datetime.now(UTC) + ) + + def test_empty_value(self): + """Comment is set to an empty string.""" + e = event.SetComments(creator=self.user, comments='') + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle empty comments') + + def test_reasonable_comment(self): + """Comment is set to some reasonable value smaller than 400 chars.""" + for locale in LOCALES: + comments = Text(locale=locale).text(20)[:400] + e = event.SetComments(creator=self.user, comments=comments) + try: + e.validate(self.submission) + except InvalidEvent as e: + self.fail('Failed to handle comments: %s' % comments) + + def test_huge_comment(self): + """Comment is set to something unreasonably large.""" + comments = Text().text(200) # 200 sentences. + e = event.SetComments(creator=self.user, comments=comments) + with self.assertRaises(InvalidEvent): + e.validate(self.submission) + + +# Locales supported by mimesis. +LOCALES = [ + "cs", + "da", + "de", + "de-at", + "de-ch", + "el", + "en", + "en-au", + "en-ca", + "en-gb", + "es", + "es-mx", + "et", + "fa", + "fi", + "fr", + "hu", + "is", + "it", + "ja", + "kk", + "ko", + "nl", + "nl-be", + "no", + "pl", + "pt", + "pt-br", + "ru", + "sv", + "tr", + "uk", + "zh", +] diff --git a/src/arxiv/submission/domain/uploads.py b/src/arxiv/submission/domain/uploads.py new file mode 100644 index 0000000..b81ce7e --- /dev/null +++ b/src/arxiv/submission/domain/uploads.py @@ -0,0 +1,153 @@ +"""Upload-related data structures.""" + +from typing import NamedTuple, List, Optional, Dict, MutableMapping, Iterable +import io +from datetime import datetime +import dateutil.parser +from enum import Enum +import io + +from .submission import Submission, SubmissionContent + + +class FileErrorLevels(Enum): + """Error severities.""" + + ERROR = 'ERROR' + WARNING = 'WARN' + + +class FileError(NamedTuple): + """Represents an error returned by the file management service.""" + + error_type: FileErrorLevels + message: str + more_info: Optional[str] = None + + def to_dict(self) -> dict: + """Generate a dict representation of this error.""" + return { + 'error_type': self.error_type, + 'message': self.message, + 'more_info': self.more_info + } + + @classmethod + def from_dict(cls: type, data: dict) -> 'FileError': + """Instantiate a :class:`FileError` from a dict.""" + instance: FileError = cls(**data) + return instance + + +class FileStatus(NamedTuple): + """Represents the state of an uploaded file.""" + + path: str + name: str + file_type: str + size: int + modified: datetime + ancillary: bool = False + errors: List[FileError] = [] + + def to_dict(self) -> dict: + """Generate a dict representation of this status object.""" + data = { + 'path': self.path, + 'name': self.name, + 'file_type': self.file_type, + 'size': self.size, + 'modified': self.modified.isoformat(), + 'ancillary': self.ancillary, + 'errors': [e.to_dict() for e in self.errors] + } + # if data['modified']: + # data['modified'] = data['modified'] + # if data['errors']: + # data['errors'] = [e.to_dict() for e in data['errors']] + return data + + @classmethod + def from_dict(cls: type, data: dict) -> 'Upload': + """Instantiate a :class:`FileStatus` from a dict.""" + if 'errors' in data: + data['errors'] = [FileError.from_dict(e) for e in data['errors']] + if 'modified' in data and type(data['modified']) is str: + data['modified'] = dateutil.parser.parse(data['modified']) + instance: Upload = cls(**data) + return instance + + +class UploadStatus(Enum): # type: ignore + """The status of the upload workspace with respect to submission.""" + + READY = 'READY' + READY_WITH_WARNINGS = 'READY_WITH_WARNINGS' + ERRORS = 'ERRORS' + +class UploadLifecycleStates(Enum): # type: ignore + """The status of the workspace with respect to its lifecycle.""" + + ACTIVE = 'ACTIVE' + RELEASED = 'RELEASED' + DELETED = 'DELETED' + + +class Upload(NamedTuple): + """Represents the state of an upload workspace.""" + + started: datetime + completed: datetime + created: datetime + modified: datetime + status: UploadStatus + lifecycle: UploadLifecycleStates + locked: bool + identifier: int + source_format: SubmissionContent.Format = SubmissionContent.Format.UNKNOWN + checksum: Optional[str] = None + size: Optional[int] = None + """Size in bytes of the uncompressed upload workspace.""" + compressed_size: Optional[int] = None + """Size in bytes of the compressed upload package.""" + files: List[FileStatus] = [] + errors: List[FileError] = [] + + @property + def file_count(self) -> int: + """The number of files in the workspace.""" + return len(self.files) + + def to_dict(self) -> dict: + """Generate a dict representation of this status object.""" + return { + 'started': self.started.isoformat(), + 'completed': self.completed.isoformat(), + 'created': self.created.isoformat(), + 'modified': self.modified.isoformat(), + 'status': self.status.value, + 'lifecycle': self.lifecycle.value, + 'locked': self.locked, + 'identifier': self.identifier, + 'source_format': self.source_format.value, + 'checksum': self.checksum, + 'size': self.size, + 'files': [d.to_dict() for d in self.files], + 'errors': [d.to_dict() for d in self.errors] + } + + @classmethod + def from_dict(cls: type, data: dict) -> 'Upload': + """Instantiate an :class:`Upload` from a dict.""" + if 'files' in data: + data['files'] = [FileStatus.from_dict(f) for f in data['files']] + if 'errors' in data: + data['errors'] = [FileError.from_dict(e) for e in data['errors']] + for key in ['started', 'completed', 'created', 'modified']: + if key in data and type(data[key]) is str: + data[key] = dateutil.parser.parse(data[key]) + if 'source_format' in data: + data['source_format'] = \ + SubmissionContent.Format(data['source_format']) + instance: Upload = cls(**data) + return instance diff --git a/src/arxiv/submission/domain/util.py b/src/arxiv/submission/domain/util.py new file mode 100644 index 0000000..74f4358 --- /dev/null +++ b/src/arxiv/submission/domain/util.py @@ -0,0 +1,19 @@ +"""Helpers and utilities.""" + +from typing import Dict, Any, List, Optional, Callable, Iterable +from datetime import datetime +from pytz import UTC + + +def get_tzaware_utc_now() -> datetime: + """Generate a datetime for the current moment in UTC.""" + return datetime.now(UTC) + + +def dict_coerce(factory: Callable[..., Any], data: dict) -> Dict[str, Any]: + return {event_id: factory(**value) if isinstance(value, dict) else value + for event_id, value in data.items()} + + +def list_coerce(factory: type, data: Iterable) -> List[Any]: + return [factory(**value) for value in data if isinstance(value, dict)] diff --git a/src/arxiv/submission/exceptions.py b/src/arxiv/submission/exceptions.py new file mode 100644 index 0000000..7ecbb55 --- /dev/null +++ b/src/arxiv/submission/exceptions.py @@ -0,0 +1,28 @@ +"""Exceptions raised during event handling.""" + +from typing import TypeVar, List + +EventType = TypeVar('EventType') + + +class InvalidEvent(ValueError): + """Raised when an invalid event is encountered.""" + + def __init__(self, event: EventType, message: str = '') -> None: + """Use the :class:`.Event` to build an error message.""" + self.event = event + self.message = message + r = f"Invalid {event.event_type}: {message}" # type: ignore + super(InvalidEvent, self).__init__(r) + + +class NoSuchSubmission(Exception): + """An operation was performed on/for a submission that does not exist.""" + + +class SaveError(RuntimeError): + """Failed to persist event state.""" + + +class NothingToDo(RuntimeError): + """There is nothing to do.""" diff --git a/src/arxiv/submission/process/__init__.py b/src/arxiv/submission/process/__init__.py new file mode 100644 index 0000000..04914c2 --- /dev/null +++ b/src/arxiv/submission/process/__init__.py @@ -0,0 +1,2 @@ +"""Core submission processes.""" + diff --git a/src/arxiv/submission/process/process_source.py b/src/arxiv/submission/process/process_source.py new file mode 100644 index 0000000..146e01b --- /dev/null +++ b/src/arxiv/submission/process/process_source.py @@ -0,0 +1,504 @@ +""" +Core procedures for processing source content. + +In order for a submission to be finalized, it must have a valid source package, +and the source must be processed. Source processing involves the transformation +(and possibly validation) of sanitized source content (generally housed in the +file manager service) into a usable preview (generally a PDF) that is housed in +the submission preview service. + +The specific steps involved in source processing vary among supported source +formats. The primary objective of this module is to encapsulate in one location +the orchestration involved in processing submission source packages. + +The end result of source processing is the generation of a +:class:`.ConfirmSourceProcessed` event. This event signifies that the source +has been processed succesfully, and that a corresponding preview may be found +in the preview service. + +Implementing support for a new format +===================================== +Processing support for a new format can be implemented by registering a new +:class:`SourceProcess`, using :func:`._make_process`. Each source process +supports a specific :class:`SubmissionContent.Format`, and should provide a +starter, a checker, and a summarizer. The preferred approach is to extend the +base classes, :class:`.BaseStarter` and :class:`.BaseChecker`. + +Using a process +=============== +The primary API of this module is comprised of the functions :func:`start` and +:func:`check`. These functions dispatch to the processes defined/registered in +this module. + +""" +import io +from typing import IO, Dict, Tuple, NamedTuple, Optional, Any, Callable, Type, Protocol + +from mypy_extensions import TypedDict + +# Mypy has a hard time with namespace packages. See +# https://github.com/python/mypy/issues/5759 +from arxiv.base import logging # type: ignore +from arxiv.integration.api.exceptions import NotFound # type: ignore +from .. import save, User, Client +from ..domain import Preview, SubmissionContent, Submission, Compilation +from ..domain.event import ConfirmSourceProcessed, UnConfirmSourceProcessed +from ..services import PreviewService, Compiler, Filemanager + +logger = logging.getLogger(__name__) + +Status = str +SUCCEEDED: Status = 'succeeded' +FAILED: Status = 'failed' +IN_PROGRESS: Status = 'in_progress' +NOT_STARTED: Status = 'not_started' + +Summary = Dict[str, Any] +"""Summary information suitable for generating a response to users/clients.""" + +class IProcess(Protocol): + """Interface for processing classes.""" + + def __init__(self, submission: Submission, user: User, + client: Optional[Client], token: str) -> None: + """Initialize the process with a submission and agent context.""" + ... + + def __call__(self) -> 'CheckResult': + """Perform the process step.""" + ... + + +class SourceProcess(NamedTuple): + """Container for source processing routines for a specific format.""" + + supports: SubmissionContent.Format + """The source format supported by this process.""" + + start: Type[IProcess] + """A function for starting processing.""" + + check: Type[IProcess] + """A function for checking the status of processing.""" + + +class CheckResult(NamedTuple): + """Information about the result of a check.""" + + status: Status + """The status of source processing.""" + + extra: Dict[str, Any] + """ + Additional data, which may vary by source type and status. + + Summary information suitable for generating feedback to an end user or API + consumer. E.g. to be injected in a template rendering context. + """ + + +_PROCESSES: Dict[SubmissionContent.Format, SourceProcess] = {} + + +# These exceptions refer to errors encountered during checking, and not to the +# status of source processing itself. +class SourceProcessingException(RuntimeError): + """Base exception for this module.""" + + +class FailedToCheckStatus(SourceProcessingException): + """Could not check the status of processing.""" + + +class NoProcessToCheck(SourceProcessingException): + """Attempted to check a process that does not exist.""" + + +class FailedToStart(SourceProcessingException): + """Could not start processing.""" + + +class FailedToGetResult(SourceProcessingException): + """Could not get the result of processing.""" + + +class _ProcessBase: + """Base class for processing steps.""" + + submission: Submission + user: User + client: Optional[Client] + token: str + extra: Dict[str, Any] + status: Optional[Status] + preview: Optional[Preview] + + def __init__(self, submission: Submission, user: User, + client: Optional[Client], token: str) -> None: + """Initialize with a submission.""" + self.submission = submission + self.user = user + self.client = client + self.token = token + self.extra = {} + self.status = None + self.preview = None + + def _deposit(self, stream: IO[bytes], content_checksum: str) -> None: + """Deposit the preview, and set :attr:`.preview`.""" + assert self.submission.source_content is not None + # It is possible that the content is already there, we just failed to + # update the submission last time. In the future we might do a more + # efficient check, but this is fine for now. + p = PreviewService.current_session() + self.preview = p.deposit(self.submission.source_content.identifier, + self.submission.source_content.checksum, + stream, self.token, overwrite=True, + content_checksum=content_checksum) + + def _confirm_processed(self) -> None: + if self.preview is None: + raise RuntimeError('Cannot confirm processing without a preview') + event = ConfirmSourceProcessed( # type: ignore + creator=self.user, + client=self.client, + source_id=self.preview.source_id, + source_checksum=self.preview.source_checksum, + preview_checksum=self.preview.preview_checksum, + size_bytes=self.preview.size_bytes, + added=self.preview.added + ) + self.submission, _ = save(event, + submission_id=self.submission.submission_id) + + def _unconfirm_processed(self) -> None: + assert self.submission.submission_id is not None + if not self.submission.is_source_processed: + return + event = UnConfirmSourceProcessed(creator=self.user, client=self.client) # type: ignore + self.submission, _ = save(event, + submission_id=self.submission.submission_id) + + def finish(self, stream: IO[bytes], content_checksum: str) -> None: + """ + Wraps up by depositing the preview and updating the submission. + + This should be called by a terminal processing implementation, as the + appropriate moment to do this may vary among workflows. + """ + self._deposit(stream, content_checksum) + self._confirm_processed() + + +class BaseStarter(_ProcessBase): + """ + Base class for starting processing. + + To extend this class, override :func:`BaseStarter.start`. That function + should perform whatever steps are necessary to start processing, and + return a :const:`.Status` that indicates the disposition of + processing for that submission. + """ + + def start(self) -> Tuple[Status, Dict[str, Any]]: + """Start processing the source. Must be implemented by child class.""" + raise NotImplementedError('Must be implemented by a child class') + + def __call__(self) -> CheckResult: + """Start processing a submission source package.""" + try: + self._unconfirm_processed() + self.status, extra = self.start() + self.extra.update(extra) + except SourceProcessingException: # Propagate. + raise + # except Exception as e: + # message = f'Could not start: {self.submission.submission_id}' + # logger.error('Caught unexpected exception: %s', e) + # raise FailedToStart(message) from e + return CheckResult(status=self.status, extra=self.extra) + + +class BaseChecker(_ProcessBase): + """ + Base class for checking the status of processing. + + To extend this class, override :func:`BaseStarter.check`. That function + should return a :const:`.Status` that indicates the disposition of + processing for a given submission. + """ + + def check(self) -> Tuple[Status, Dict[str, Any]]: + """Perform the status check.""" + raise NotImplementedError('Must be implemented by a subclass') + + def _pre_check(self) -> None: + assert self.submission.source_content is not None + if self.submission.is_source_processed \ + and self.submission.preview is not None: + p = PreviewService.current_session() + is_ok = p.has_preview(self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token, + self.submission.preview.preview_checksum) + if is_ok: + self.extra.update({'preview': self.submission.preview}) + self.status = SUCCEEDED + + def __call__(self) -> CheckResult: + """Check the status of source processing for a submission.""" + try: + self._pre_check() + self.status, extra = self.check() + self.extra.update(extra) + except SourceProcessingException: # Propagate. + raise + except Exception as e: + raise FailedToCheckStatus(f'Status check failed: {e}') from e + return CheckResult(status=self.status, extra=self.extra) + + +class _PDFStarter(BaseStarter): + """Start processing a PDF source package.""" + + def start(self) -> Tuple[Status, Dict[str, Any]]: + """Retrieve the PDF from the file manager service and finish.""" + if self.submission.source_content is None: + return FAILED, {'reason': 'Submission has no source package'} + m = Filemanager.current_session() + try: + stream, checksum, content_checksum = \ + m.get_single_file(self.submission.source_content.identifier, + self.token) + except NotFound: + return FAILED, {'reason': 'Does not have a single PDF file.'} + if self.submission.source_content.checksum != checksum: + logger.error('source checksum and retrieved checksum do not match;' + f' expected {self.submission.source_content.checksum}' + f' but got {checksum}') + return FAILED, {'reason': 'Source has changed.'} + + self.finish(stream, content_checksum) + return SUCCEEDED, {} + + +class _PDFChecker(BaseChecker): + """Check the status of a PDF source package.""" + + def check(self) -> Tuple[Status, Dict[str, Any]]: + """Verify that the preview is present.""" + if self.submission.source_content is None: + return FAILED, {'reason': 'Submission has no source package'} + if self.status is not None: + return self.status, {} + p = PreviewService.current_session() + try: + preview = p.get_metadata( + self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token + ) + except NotFound: + return NOT_STARTED, {} + if self.submission.source_content.checksum != preview.source_checksum: + return NOT_STARTED, {'reason': 'Source has changed.'} + self.preview = preview + return SUCCEEDED, {} + + +class _CompilationStarter(BaseStarter): + """Starts compilation via the compiler service.""" + + def start(self) -> Tuple[Status, Dict[str, Any]]: + """Start compilation.""" + if self.submission.source_content is None: + return FAILED, {'reason': 'Submission has no source package'} + c = Compiler.current_session() + stat = c.compile(self.submission.source_content.identifier, + self.submission.source_content.checksum, self.token, + *self._make_stamp(), force=True) + + # There is no good reason for this to come back as failed right off + # the bat, so we will treat it as a bona fide exception rather than + # just FAILED state. + if stat.is_failed: + raise FailedToStart(f'Failed to start: {stat.Reason.value}') + + # If we got this far, we're off to the races. + return IN_PROGRESS, {} + + def _make_stamp(self) -> Tuple[str, str]: + """ + Create label and link for PS/PDF stamp/watermark. + + Stamp format for submission is of form ``[identifier category date]`` + + ``arXiv:submit/ [] DD MON YYYY`` + + Date segment is optional and added automatically by converter. + """ + stamp_label = f'arXiv:submit/{self.submission.submission_id}' + + if self.submission.primary_classification \ + and self.submission.primary_classification.category: + # Create stamp label string - for now we'll let converter + # add date segment to stamp label + primary_category = self.submission.primary_classification.category + stamp_label = f'{stamp_label} [{primary_category}]' + + stamp_link = f'/{self.submission.submission_id}/preview.pdf' + return stamp_label, stamp_link + + +class _CompilationChecker(BaseChecker): + def check(self) -> Tuple[Status, Dict[str, Any]]: + """Check the status of compilation, and finish if succeeded.""" + if self.submission.source_content is None: + return FAILED, {'reason': 'Submission has no source package'} + status: Status = self.status or IN_PROGRESS + extra: Dict[str, Any] = {} + comp: Optional[Compilation] = None + c = Compiler.current_session() + if status not in [SUCCEEDED, FAILED]: + try: + comp = c.get_status(self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token) + extra.update({'compilation': comp}) + except NotFound: # Nothing to do. + return NOT_STARTED, extra + + # Ship the product to preview and confirm processing. We only want to + # do this once. The pre-check will have set a status if it is known + # ahead of time. + if status is IN_PROGRESS and comp is not None and comp.is_succeeded: + # Ship the compiled PDF off to the preview service. + prod = c.get_product(self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token) + self.finish(prod.stream, prod.checksum) + status = SUCCEEDED + elif comp is not None and comp.is_failed: + status = FAILED + extra.update({'reason': comp.reason.value, + 'description': comp.description}) + + # Get the log output for both success and failure. + log_output: Optional[str] = None + if status in [SUCCEEDED, FAILED]: + try: + log = c.get_log(self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token) + log_output = log.stream.read().decode('utf-8') + except NotFound: + log_output = None + extra.update({'log_output': log_output}) + return status, extra + + +def _make_process(supports: SubmissionContent.Format, starter: Type[IProcess], + checker: Type[IProcess]) -> SourceProcess: + + proc = SourceProcess(supports, starter, checker) + _PROCESSES[supports] = proc + return proc + + +def _get_process(source_format: SubmissionContent.Format) -> SourceProcess: + proc = _PROCESSES.get(source_format, None) + if proc is None: + raise NotImplementedError(f'No process found for {source_format}') + return proc + + +def _get_and_call_starter(submission: Submission, user: User, + client: Optional[Client], token: str) -> CheckResult: + assert submission.source_content is not None + proc = _get_process(submission.source_content.source_format) + return proc.start(submission, user, client, token)() + + +def _get_and_call_checker(submission: Submission, user: User, + client: Optional[Client], token: str) -> CheckResult: + assert submission.source_content is not None + proc = _get_process(submission.source_content.source_format) + return proc.check(submission, user, client, token)() + + +def start(submission: Submission, user: User, client: Optional[Client], + token: str) -> CheckResult: + """ + Start processing the source package for a submission. + + Parameters + ---------- + submission : :class:`.Submission` + The submission to process. + user : :class:`.User` + arXiv user who originated the request. + client : :class:`.Client` or None + API client that handled the request, if any. + token : str + Authn/z token for the request. + + Returns + ------- + :class:`.CheckResult` + Status indicates the disposition of the process. + + Raises + ------ + :class:`NotImplementedError` + Raised if the submission source format is not supported by this module. + + """ + return _get_and_call_starter(submission, user, client, token) + + +def check(submission: Submission, user: User, client: Optional[Client], + token: str) -> CheckResult: + """ + Check the status of source processing for a submission. + + Parameters + ---------- + submission : :class:`.Submission` + The submission to process. + user : :class:`.User` + arXiv user who originated the request. + client : :class:`.Client` or None + API client that handled the request, if any. + token : str + Authn/z token for the request. + + Returns + ------- + :class:`.CheckResult` + Status indicates the disposition of the process. + + Raises + ------ + :class:`NotImplementedError` + Raised if the submission source format is not supported by this module. + + """ + return _get_and_call_checker(submission, user, client, token) + + +TeXProcess = _make_process(SubmissionContent.Format.TEX, + _CompilationStarter, + _CompilationChecker) +"""Support for processing TeX submissions.""" + + +PostscriptProcess = _make_process(SubmissionContent.Format.POSTSCRIPT, + _CompilationStarter, + _CompilationChecker) +"""Support for processing Postscript submissions.""" + + +PDFProcess = _make_process(SubmissionContent.Format.PDF, + _PDFStarter, + _PDFChecker) +"""Support for processing PDF submissions.""" diff --git a/src/arxiv/submission/process/tests.py b/src/arxiv/submission/process/tests.py new file mode 100644 index 0000000..5b47f36 --- /dev/null +++ b/src/arxiv/submission/process/tests.py @@ -0,0 +1,537 @@ +"""Tests for :mod:`.process.process_source`.""" + +import io +from datetime import datetime +from unittest import TestCase, mock + +from pytz import UTC + +from arxiv.integration.api.exceptions import RequestFailed, NotFound + +from ..domain import Submission, SubmissionContent, User, Client +from ..domain.event import ConfirmSourceProcessed, UnConfirmSourceProcessed +from ..domain.preview import Preview +from . import process_source +from .. import SaveError +from .process_source import start, check, SUCCEEDED, FAILED, IN_PROGRESS, \ + NOT_STARTED + +PDF = SubmissionContent.Format.PDF +TEX = SubmissionContent.Format.TEX + + +def raise_RequestFailed(*args, **kwargs): + raise RequestFailed('foo', mock.MagicMock()) + + +def raise_NotFound(*args, **kwargs): + raise NotFound('foo', mock.MagicMock()) + + +class PDFFormatTest(TestCase): + """Test case for PDF format processing.""" + + def setUp(self): + """We have a submission with a PDF source package.""" + self.content = mock.MagicMock(spec=SubmissionContent, + identifier=1234, + checksum='foochex==', + source_format=PDF) + self.submission = mock.MagicMock(spec=Submission, + submission_id=42, + source_content=self.content, + is_source_processed=False, + preview=None) + self.user = mock.MagicMock(spec=User) + self.client = mock.MagicMock(spec=Client) + self.token = 'footoken' + + +class TestStartProcessingPDF(PDFFormatTest): + """Test :const:`.PDFProcess`.""" + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Filemanager') + def test_start(self, mock_Filemanager, mock_PreviewService, mock_save): + """Start processing the PDF source.""" + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = False + mock_preview_service.deposit.return_value = mock.MagicMock( + spec=Preview, + source_id=1234, + source_checksum='foochex==', + preview_checksum='foochex==', + size_bytes=1234578, + added=datetime.now(UTC) + ) + mock_PreviewService.current_session.return_value = mock_preview_service + + mock_filemanager = mock.MagicMock() + stream = io.BytesIO(b'fakecontent') + mock_filemanager.get_single_file.return_value = ( + stream, + 'foochex==', + 'contentchex==' + ) + mock_Filemanager.current_session.return_value = mock_filemanager + + mock_save.return_value = (self.submission, []) + + data = start(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") + + mock_preview_service.deposit.assert_called_once_with( + self.content.identifier, + self.content.checksum, + stream, + self.token, + content_checksum='contentchex==', + overwrite=True + ) + + mock_save.assert_called_once() + args, kwargs = mock_save.call_args + self.assertIsInstance(args[0], ConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Filemanager') + def test_already_done(self, mock_Filemanager, mock_PreviewService, + mock_save): + """Attempt to start processing a source that is already processed.""" + self.submission.is_source_processed = True + self.submission.preview = mock.MagicMock() + self.submission.source_content.checksum = 'foochex==' + + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = False + mock_preview_service.deposit.return_value = mock.MagicMock( + spec=Preview, + source_id=1234, + source_checksum='foochex==', + preview_checksum='pvwchex==', + size_bytes=1234578, + added=datetime.now(UTC) + ) + mock_PreviewService.current_session.return_value = mock_preview_service + + mock_filemanager = mock.MagicMock() + stream = io.BytesIO(b'fakecontent') + mock_filemanager.get_single_file.return_value = ( + stream, + 'foochex==', + 'pvwchex==' + ) + mock_Filemanager.current_session.return_value = mock_filemanager + + mock_save.return_value = (self.submission, []) + + data = start(self.submission, self.user, self.client, self.token) + + self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") + + # Evaluate deposit to preview service. + mock_preview_service.deposit.assert_called_once_with( + self.content.identifier, + self.content.checksum, + stream, + self.token, + content_checksum='pvwchex==', + overwrite=True + ) + + # Evaluate calls to save() + self.assertEqual(mock_save.call_count, 2, 'Save called twice') + calls = mock_save.call_args_list + # First call is to unconfirm processing. + args, kwargs = calls[0] + self.assertIsInstance(args[0], UnConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) + + # Second call is to confirm processing. + args, kwargs = calls[1] + self.assertIsInstance(args[0], ConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) + + @mock.patch(f'{process_source.__name__}.Filemanager') + def test_start_preview_fails(self, mock_Filemanager): + """No single PDF file available to use.""" + mock_filemanager = mock.MagicMock() + stream = io.BytesIO(b'fakecontent') + mock_filemanager.get_single_file.side_effect = raise_NotFound + mock_Filemanager.current_session.return_value = mock_filemanager + + data = start(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, FAILED, 'Failed to start') + + mock_filemanager.get_single_file.assert_called_once_with( + self.content.identifier, + self.token + ) + + +class TestCheckPDF(PDFFormatTest): + """Test :const:`.PDFProcess`.""" + + @mock.patch(f'{process_source.__name__}.PreviewService') + def test_check_successful(self, mock_PreviewService): + """Source is processed and a preview is present.""" + self.submission.is_source_processed = True + self.submission.preview = mock.MagicMock( + spec=Preview, + source_id=1234, + source_checksum='foochex==', + preview_checksum='foochex==', + size_bytes=1234578, + added=datetime.now(UTC) + ) + + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = True + mock_PreviewService.current_session.return_value = mock_preview_service + + data = check(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, SUCCEEDED) + self.assertIn('preview', data.extra) + + mock_preview_service.has_preview.assert_called_once_with( + self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token, + self.submission.preview.preview_checksum + ) + + @mock.patch(f'{process_source.__name__}.PreviewService') + def test_check_preview_not_found(self, mock_PreviewService): + """Source is not processed, and there is no preview.""" + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = False + mock_preview_service.get_metadata.side_effect = raise_NotFound + mock_PreviewService.current_session.return_value = mock_preview_service + + data = check(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, NOT_STARTED) + self.assertNotIn('preview', data.extra) + + mock_preview_service.get_metadata.assert_called_once_with( + self.submission.source_content.identifier, + self.submission.source_content.checksum, + self.token + ) + + +class TeXFormatTestCase(TestCase): + """Test case for TeX format processing.""" + + def setUp(self): + """We have a submission with a TeX source package.""" + self.content = mock.MagicMock(spec=SubmissionContent, + identifier=1234, + checksum='foochex==', + source_format=TEX) + self.submission = mock.MagicMock(spec=Submission, + submission_id=42, + source_content=self.content, + is_source_processed=False, + preview=None) + self.submission.primary_classification.category = 'cs.DL' + self.user = mock.MagicMock(spec=User) + self.client = mock.MagicMock(spec=Client) + self.token = 'footoken' + + +class TestStartTeX(TeXFormatTestCase): + """Test the start of processing a TeX source.""" + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_start(self, mock_Compiler): + """Start is successful, in progress.""" + mock_compiler = mock.MagicMock() + mock_compiler.compile.return_value = mock.MagicMock(is_failed=False, + is_succeeded=False) + mock_Compiler.current_session.return_value = mock_compiler + data = start(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, IN_PROGRESS, "Processing is in progress") + + mock_compiler.compile.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token, + 'arXiv:submit/42 [cs.DL]', + '/42/preview.pdf', + force=True + ) + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_start_failed(self, mock_Compiler): + """Compilation starts, but fails immediately.""" + mock_compiler = mock.MagicMock() + mock_compiler.compile.return_value = mock.MagicMock(is_failed=True, + is_succeeded=False) + mock_Compiler.current_session.return_value = mock_compiler + with self.assertRaises(process_source.FailedToStart): + start(self.submission, self.user, self.client, self.token) + + mock_compiler.compile.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token, + 'arXiv:submit/42 [cs.DL]', + '/42/preview.pdf', + force=True + ) + + +class TestCheckTeX(TeXFormatTestCase): + """Test the status check for processing a TeX source.""" + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_in_progress(self, mock_Compiler): + """Check processing, still in progress""" + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=False, + is_failed=False) + mock_compiler.get_status.return_value = mock_compilation + mock_Compiler.current_session.return_value = mock_compiler + + data = check(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, IN_PROGRESS, "Processing is in progress") + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_nonexistant(self, mock_Compiler): + """Check processing for no such compilation.""" + mock_compiler = mock.MagicMock() + mock_compiler.get_status.side_effect = raise_NotFound + mock_Compiler.current_session.return_value = mock_compiler + data = check(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, NOT_STARTED, 'Process not started') + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_exception(self, mock_Compiler): + """Compiler service raises an exception""" + mock_compiler = mock.MagicMock() + mock_compiler.get_status.side_effect = RuntimeError + mock_Compiler.current_session.return_value = mock_compiler + with self.assertRaises(process_source.FailedToCheckStatus): + check(self.submission, self.user, self.client, self.token) + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_failed(self, mock_Compiler): + """Check processing, compilation failed.""" + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=False, + is_failed=True) + mock_compiler.get_status.return_value = mock_compilation + mock_Compiler.current_session.return_value = mock_compiler + + data = check(self.submission, self.user, self.client, self.token) + self.assertEqual(data.status, FAILED, "Processing failed") + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_succeeded(self, mock_Compiler, mock_PreviewService, + mock_save): + """Check processing, compilation succeeded.""" + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = False + mock_PreviewService.current_session.return_value = mock_preview_service + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=True, + is_failed=False) + mock_compiler.get_status.return_value = mock_compilation + stream = io.BytesIO(b'foobytes') + mock_compiler.get_product.return_value = mock.MagicMock( + stream=stream, + checksum='chx' + ) + mock_Compiler.current_session.return_value = mock_compiler + + mock_save.return_value = (self.submission, []) + + data = check(self.submission, self.user, self.client, self.token) + + self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + mock_compiler.get_product.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + mock_preview_service.deposit.assert_called_once_with( + self.content.identifier, + self.content.checksum, + stream, + self.token, + content_checksum='chx', + overwrite=True + ) + mock_save.assert_called_once() + args, kwargs = mock_save.call_args + self.assertIsInstance(args[0], ConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Compiler') + def test_succeeded_preview_shipped_not_marked(self, mock_Compiler, + mock_PreviewService, + mock_save): + """Preview already shipped, but submission not updated.""" + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = True + mock_PreviewService.current_session.return_value = mock_preview_service + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=True, + is_failed=False) + mock_compiler.get_status.return_value = mock_compilation + stream = io.BytesIO(b'foobytes') + mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, + checksum='chx') + mock_Compiler.current_session.return_value = mock_compiler + + mock_save.return_value = (self.submission, []) + + data = check(self.submission, self.user, self.client, self.token) + + self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") + + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + + mock_compiler.get_product.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + mock_preview_service.deposit.assert_called_once_with( + self.content.identifier, + self.content.checksum, + stream, + self.token, + content_checksum='chx', + overwrite=True + ) + + mock_save.assert_called_once() + args, kwargs = mock_save.call_args + self.assertIsInstance(args[0], ConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Compiler') + def test_succeeded_preview_shipped_and_marked(self, mock_Compiler, + mock_PreviewService, + mock_save): + """Preview already shipped and submission is up to date.""" + self.submission.preview = mock.MagicMock( + source_id=self.content.identifier, + checksum=self.content.checksum, + preview_checksum='chx' + ) + self.submission.is_source_processed = True + + mock_preview_service = mock.MagicMock() + mock_preview_service.has_preview.return_value = True + mock_PreviewService.current_session.return_value = mock_preview_service + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=True, + is_failed=False) + mock_compiler.get_status.return_value = mock_compilation + stream = io.BytesIO(b'foobytes') + mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, + checksum='chx') + mock_Compiler.current_session.return_value = mock_compiler + + mock_save.return_value = (self.submission, []) + + data = check(self.submission, self.user, self.client, self.token) + + self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") + + mock_compiler.get_status.assert_not_called() + mock_compiler.get_product.assert_not_called() + mock_preview_service.deposit.assert_not_called() + mock_save.assert_not_called() + + @mock.patch(f'{process_source.__name__}.save') + @mock.patch(f'{process_source.__name__}.PreviewService') + @mock.patch(f'{process_source.__name__}.Compiler') + def test_check_succeeded_save_error(self, mock_Compiler, + mock_PreviewService, + mock_save): + """Compilation succeeded, but could not save event.""" + mock_preview_service = mock.MagicMock() + mock_PreviewService.current_session.return_value = mock_preview_service + mock_compiler = mock.MagicMock() + mock_compilation = mock.MagicMock(is_succeeded=True, + is_failed=False) + mock_compiler.get_status.return_value = mock_compilation + stream = io.BytesIO(b'foobytes') + mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, + checksum='chx') + mock_Compiler.current_session.return_value = mock_compiler + + mock_save.side_effect = SaveError + + with self.assertRaises(process_source.FailedToCheckStatus): + check(self.submission, self.user, self.client, self.token) + + mock_compiler.get_status.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + mock_compiler.get_product.assert_called_once_with( + self.content.identifier, + self.content.checksum, + self.token + ) + mock_preview_service.deposit.assert_called_once_with( + self.content.identifier, + self.content.checksum, + stream, + self.token, + content_checksum='chx', + overwrite=True + ) + mock_save.assert_called_once() + args, kwargs = mock_save.call_args + self.assertIsInstance(args[0], ConfirmSourceProcessed) + self.assertEqual(kwargs['submission_id'], + self.submission.submission_id) \ No newline at end of file diff --git a/src/arxiv/submission/schedule.py b/src/arxiv/submission/schedule.py new file mode 100644 index 0000000..a78e2c9 --- /dev/null +++ b/src/arxiv/submission/schedule.py @@ -0,0 +1,82 @@ +""" +Policies for announcement scheduling. + +Submissions to arXiv are normally made public on Sunday through Thursday, with +no announcements Friday or Saturday. + ++-----------------------+----------------+------------------------------------+ +| Received Between (ET) | Announced (ET) | Mailed | ++=======================+================+====================================+ +| Mon 14:00 - Tue 14:00 | Tue 20:00 | Tuesday Night / Wednesday Morning | +| Tue 14:00 - Wed 14:00 | Wed 20:00 | Wednesday Night / Thursday Morning | +| Wed 14:00 - Thu 14:00 | Thu 20:00 | Thursday Night / Friday Morning | +| Thu 14:00 - Fri 14:00 | Sun 20:00 | Sunday Night / Monday Morning | +| Fri 14:00 - Mon 14:00 | Mon 20:00 | Monday Night / Tuesday Morning | ++-----------------------+----------------+------------------------------------+ + +""" + +from typing import Optional +from datetime import datetime, timedelta +from enum import IntEnum, Enum +from pytz import timezone, UTC + +ET = timezone('US/Eastern') + + +# I preferred the callable construction of IntEnum to the class-based +# construction, but this is more typing-friendly. +class Weekdays(IntEnum): + """Numeric representation of the days of the week.""" + + Mon = 1 + Tue = 2 + Wed = 3 + Thu = 4 + Fri = 5 + Sat = 6 + Sun = 7 + + +ANNOUNCE_TIME = 20 # Hours (8pm ET) +FREEZE_TIME = 14 # Hours (2pm ET) + +WINDOWS = [ + ((Weekdays.Fri - 7, 14), (Weekdays.Mon, 14), (Weekdays.Mon, 20)), + ((Weekdays.Mon, 14), (Weekdays.Tue, 14), (Weekdays.Tue, 20)), + ((Weekdays.Tue, 14), (Weekdays.Wed, 14), (Weekdays.Wed, 20)), + ((Weekdays.Wed, 14), (Weekdays.Thu, 14), (Weekdays.Thu, 20)), + ((Weekdays.Thu, 14), (Weekdays.Fri, 14), (Weekdays.Sun, 20)), + ((Weekdays.Fri, 14), (Weekdays.Mon + 7, 14), (Weekdays.Mon + 7, 20)), +] + + +def _datetime(ref: datetime, isoweekday: int, hour: int) -> datetime: + days_hence = isoweekday - ref.isoweekday() + # repl = dict(hour=hour, minute=0, second=0, microsecond=0) + dt = (ref + timedelta(days=days_hence)) + return dt.replace(hour=hour, minute=0, second=0, microsecond=0) + + +def next_announcement_time(ref: Optional[datetime] = None) -> datetime: + """Get the datetime of the next announcement.""" + if ref is None: + ref = ET.localize(datetime.now()) + else: + ref = ref.astimezone(ET) + for start, end, announce in WINDOWS: + if _datetime(ref, *start) <= ref < _datetime(ref, *end): + return _datetime(ref, *announce) + raise RuntimeError('Could not arrive at next announcement time') + + +def next_freeze_time(ref: Optional[datetime] = None) -> datetime: + """Get the datetime of the next freeze.""" + if ref is None: + ref = ET.localize(datetime.now()) + else: + ref = ref.astimezone(ET) + for start, end, announce in WINDOWS: + if _datetime(ref, *start) <= ref < _datetime(ref, *end): + return _datetime(ref, *end) + raise RuntimeError('Could not arrive at next freeze time') diff --git a/src/arxiv/submission/serializer.py b/src/arxiv/submission/serializer.py new file mode 100644 index 0000000..e79253a --- /dev/null +++ b/src/arxiv/submission/serializer.py @@ -0,0 +1,97 @@ +"""JSON serialization for submission core.""" + +import json +from datetime import datetime, date +from enum import Enum +from importlib import import_module +from json.decoder import JSONDecodeError +from typing import Any, Union, List + +from backports.datetime_fromisoformat import MonkeyPatch +from dataclasses import asdict + +from arxiv.util.serialize import ISO8601JSONEncoder, ISO8601JSONDecoder + +from .domain import Event, event_factory, Submission, Agent, agent_factory + +MonkeyPatch.patch_fromisoformat() + + +class EventJSONEncoder(ISO8601JSONEncoder): + """Encodes domain objects in this package for serialization.""" + + def default(self, obj: object) -> Any: + """Look for domain objects, and use their dict-coercion methods.""" + if isinstance(obj, Event): + data = asdict(obj) + data['__type__'] = 'event' + elif isinstance(obj, Submission): + data = asdict(obj) + data.pop('before', None) + data.pop('after', None) + data['__type__'] = 'submission' + elif isinstance(obj, Agent): + data = asdict(obj) + data['__type__'] = 'agent' + elif isinstance(obj, type): + data = {} + data['__module__'] = obj.__module__ + data['__name__'] = obj.__name__ + data['__type__'] = 'type' + elif isinstance(obj, Enum): + data = obj.value + else: + data = super(EventJSONEncoder, self).default(obj) + return data + + +class EventJSONDecoder(ISO8601JSONDecoder): + """Decode :class:`.Event` and other domain objects from JSON data.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Pass :func:`object_hook` to the base constructor.""" + kwargs['object_hook'] = kwargs.get('object_hook', self.object_hook) + super(EventJSONDecoder, self).__init__(*args, **kwargs) + + def object_hook(self, obj: dict, **extra: Any) -> Any: + """Decode domain objects in this package.""" + obj = super(EventJSONDecoder, self).object_hook(obj, **extra) + + if '__type__' in obj: + if obj['__type__'] == 'event': + obj.pop('__type__') + return event_factory(obj.pop('event_type'), + obj.pop('created'), + **obj) + elif obj['__type__'] == 'submission': + obj.pop('__type__') + return Submission(**obj) + elif obj['__type__'] == 'agent': + obj.pop('__type__') + return agent_factory(**obj) + elif obj['__type__'] == 'type': + # Supports deserialization of Event classes. + # + # This is fairly dangerous, since we are importing and calling + # an arbitrary object specified in data. We need to be sure to + # check that the object originates in this package, and that it + # is actually a child of Event. + module_name = obj['__module__'] + if not (module_name.startswith('arxiv.submission') + or module_name.startswith('submission')): + raise JSONDecodeError(module_name, '', pos=0) + cls = getattr(import_module(module_name), obj['__name__']) + if Event not in cls.mro(): + raise JSONDecodeError(obj['__name__'], '', pos=0) + return cls + return obj + + +def dumps(obj: Any) -> str: + """Generate JSON from a Python object.""" + return json.dumps(obj, cls=EventJSONEncoder) + + +def loads(data: str) -> Any: + """Load a Python object from JSON.""" + return json.loads(data, cls=EventJSONDecoder) diff --git a/src/arxiv/submission/services/__init__.py b/src/arxiv/submission/services/__init__.py new file mode 100644 index 0000000..f6d2fe2 --- /dev/null +++ b/src/arxiv/submission/services/__init__.py @@ -0,0 +1,8 @@ +"""External service integrations.""" + +from .classifier import Classifier +from .compiler import Compiler +from .filemanager import Filemanager +from .plaintext import PlainTextService +from .preview import PreviewService +from .stream import StreamPublisher diff --git a/src/arxiv/submission/services/classic/__init__.py b/src/arxiv/submission/services/classic/__init__.py new file mode 100644 index 0000000..1dc807e --- /dev/null +++ b/src/arxiv/submission/services/classic/__init__.py @@ -0,0 +1,719 @@ +""" +Integration with the classic database to persist events and submission state. + +As part of the classic renewal strategy, development of new submission +interfaces must maintain data interoperability with classic components. This +service module must therefore do three main things: + +1. Store and provide access to event data generated during the submission + process, +2. Keep the classic database tables up to date so that "downstream" components + can continue to operate. +3. Patch NG submission data with state changes that occur in the classic + system. Those changes will be made directly to submission tables and not + involve event-generation. See :func:`get_submission` for details. + +Since classic components work directly on submission tables, persisting events +and resulting submission state must occur in the same transaction. We must also +verify that we are not storing events that are stale with respect to the +current state of the submission. To achieve this, the caller should use the +:func:`.util.transaction` context manager, and (when committing new events) +call :func:`.get_submission` with ``for_update=True``. This will trigger a +shared lock on the submission row(s) involved until the transaction is +committed or rolled back. + +ORM representations of the classic database tables involved in submission +are located in :mod:`.classic.models`. An additional model, :class:`.DBEvent`, +is defined in :mod:`.classic.event`. + +See also :ref:`legacy-integration`. + +""" + +from typing import List, Optional, Tuple, Set, Callable, Any, TypeVar, cast +from retry import retry as _retry +from datetime import datetime +from operator import attrgetter +from pytz import UTC +from itertools import groupby +import copy +import traceback +from functools import reduce, wraps +from operator import ior +from dataclasses import asdict + +from flask import Flask +from sqlalchemy import or_, text +from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import DBAPIError, OperationalError + +from arxiv.base import logging +from arxiv.base.globals import get_application_config, get_application_global +from ...domain.event import Event, Announce, RequestWithdrawal, SetDOI, \ + SetJournalReference, SetReportNumber, Rollback, RequestCrossList, \ + ApplyRequest, RejectRequest, ApproveRequest, AddProposal, CancelRequest, \ + CreateSubmission + +from ...domain.submission import License, Submission, WithdrawalRequest, \ + CrossListClassificationRequest +from ...domain.agent import Agent, User +from .models import Base +from .exceptions import ClassicBaseException, NoSuchSubmission, \ + TransactionFailed, Unavailable, ConsistencyError +from .util import transaction, current_session, db +from .event import DBEvent +from . import models, util, interpolate, log, proposal, load + + +logger = logging.getLogger(__name__) +logger.propagate = False + +JREFEvents = [SetDOI, SetJournalReference, SetReportNumber] + +FuncType = Callable[..., Any] +F = TypeVar('F', bound=FuncType) + +# retry = _retry +retry: Callable[..., Callable[[F], F]] = _retry +# wraps: Callable[[F], F] = _wraps + + +def handle_operational_errors(func: F) -> F: + """Catch SQLAlchemy OperationalErrors and raise :class:`.Unavailable`.""" + @wraps(func) + def inner(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except OperationalError as e: + logger.error('Encountered an OperationalError calling %s', + func.__name__) + # This will put the traceback in the log, and it may look like an + # unhandled exception (even though it is not). + logger.error('==== OperationalError: handled traceback start ====') + logger.error(traceback.format_exc()) + logger.error('==== OperationalError: handled traceback end ====') + raise Unavailable('Classic database unavailable') from e + # return inner + return cast(F, inner) + + +def is_available(**kwargs: Any) -> bool: + """Check our connection to the database.""" + try: + logger.info('Checking Classic is available') + _check_available() + except Unavailable as e: + logger.info('Database not available: %s', e) + return False + return True + + +@handle_operational_errors +def _check_available() -> None: + """Execute ``SELECT 1`` against the database.""" + #current_session().query("1").from_statement(text("SELECT 1")).all() + with current_session() as session: + session.execute('SELECT 1') + +@retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_licenses() -> List[License]: + """Get a list of :class:`.domain.License` instances available.""" + license_data = current_session().query(models.License) \ + .filter(models.License.active == '1') + return [License(uri=row.name, name=row.label) for row in license_data] + + +@retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_events(submission_id: int) -> List[Event]: + """ + Load events from the classic database. + + Parameters + ---------- + submission_id : int + + Returns + ------- + list + Items are :class:`.Event` instances loaded from the class DB. + + Raises + ------ + :class:`.classic.exceptions.NoSuchSubmission` + Raised when there are no events for the provided submission ID. + + """ + session = current_session() + event_data = session.query(DBEvent) \ + .filter(DBEvent.submission_id == submission_id) \ + .order_by(DBEvent.created) + events = [datum.to_event() for datum in event_data] + if not events: # No events, no dice. + logger.error('No events for submission %s', submission_id) + raise NoSuchSubmission(f'Submission {submission_id} not found') + return events + + +@retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_user_submissions_fast(user_id: int) -> List[Submission]: + """ + Get active NG submissions for a user. + + This should not return submissions for which there are no events. + + Uses the same approach as :func:`get_submission_fast`. + + Parameters + ---------- + submission_id : int + + Returns + ------- + list + Items are the user's :class:`.domain.submission.Submission` instances. + + """ + session = current_session() + db_submissions = list( + session.query(models.Submission) + .filter(models.Submission.submitter_id == user_id) + .join(DBEvent) # Only get submissions that are also in the event table + .order_by(models.Submission.doc_paper_id.desc()) + ) + grouped = groupby(db_submissions, key=attrgetter('doc_paper_id')) + submissions: List[Optional[Submission]] = [] + for arxiv_id, dbss in grouped: + logger.debug('Handle group for arXiv ID %s: %s', arxiv_id, dbss) + if arxiv_id is None: # This is an unannounced submission. + for dbs in dbss: # Each row represents a separate e-print. + submissions.append(load.to_submission(dbs)) + else: + submissions.append( + load.load(sorted(dbss, key=lambda dbs: dbs.submission_id)) + ) + return [subm for subm in submissions if subm and not subm.is_deleted] + + +@retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_submission_fast(submission_id: int) -> Submission: + """ + Get the projection of the submission directly. + + Instead of playing events forward, we grab the most recent snapshot of the + submission in the database. Since classic represents the submission using + several rows, we have to grab all of them and transform/patch as + appropriate. + + Parameters + ---------- + submission_id : int + + Returns + ------- + :class:`.domain.submission.Submission` or ``None`` + + Raises + ------ + :class:`.classic.exceptions.NoSuchSubmission` + Raised when there are is no submission for the provided submission ID. + + """ + submission = load.load(_get_db_submission_rows(submission_id)) + if submission is None: + raise NoSuchSubmission(f'No submission found: {submission_id}') + return submission + + +# @retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_submission(submission_id: int, for_update: bool = False) \ + -> Tuple[Submission, List[Event]]: + """ + Get the current state of a submission from the database. + + In the medium term, services that use this package will need to + play well with legacy services that integrate with the classic + database. For example, the moderation system does not use the event + model implemented here, and will therefore cause direct changes to the + submission tables that must be reflected in our representation of the + submission. + + Until those legacy components are replaced, this function loads both the + event stack and the current DB state of the submission, and uses the DB + state to patch fields that may have changed outside the purview of the + event model. + + Parameters + ---------- + submission_id : int + + Returns + ------- + :class:`.domain.submission.Submission` + list + Items are :class:`Event` instances. + + """ + # Let the caller determine the transaction scope. + session = current_session() + original_row = session.query(models.Submission) \ + .filter(models.Submission.submission_id == submission_id) \ + .join(DBEvent) + + if for_update: + # Gives us SELECT ... FOR READ. In other words, lock this row for + # writing, but allow other clients to read from it in the meantime. + original_row = original_row.with_for_update(read=True) + + try: + original_row = original_row.one() + logger.debug('Got row %s', original_row) + except NoResultFound as exc: + logger.debug('Got NoResultFound exception %s', exc) + raise NoSuchSubmission(f'Submission {submission_id} not found') + # May also raise MultipleResultsFound; if so, we want to fail loudly. + + # Load any subsequent submission rows (e.g. v=2, jref, withdrawal). + # These do not have the same legacy submission ID as the original + # submission. + subsequent_rows: List[models.Submission] = [] + arxiv_id = original_row.get_arxiv_id() + if arxiv_id is not None: + subsequent_query = session.query(models.Submission) \ + .filter(models.Submission.doc_paper_id == arxiv_id) \ + .filter(models.Submission.submission_id != submission_id) \ + .order_by(models.Submission.submission_id.asc()) + + if for_update: # Lock these rows as well. + subsequent_query = subsequent_query.with_for_update(read=True) + subsequent_rows = list(subsequent_query) # Execute query. + logger.debug('Got subsequent_rows: %s', subsequent_rows) + + try: + _events = get_events(submission_id) + except NoSuchSubmission: + _events = [] + + # If this submission originated in the classic system, we will have usable + # rows from the submission table, and either no events or events that do + # not start with a CreateSubmission event. In that case, fall back to + # ``load.load()``, which relies only on classic rows. + if not _events or not isinstance(_events[0], CreateSubmission): + logger.info('Loading a classic submission: %s', submission_id) + submission = load.load([original_row] + subsequent_rows) + if submission is None: + raise NoSuchSubmission('No such submission') + return submission, [] + + # We have an NG-native submission. + interpolator = interpolate.ClassicEventInterpolator( + original_row, + subsequent_rows, + _events + ) + return interpolator.get_submission_state() + + +# @retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def store_event(event: Event, before: Optional[Submission], after: Submission, + *call: Callable) -> Tuple[Event, Submission]: + """ + Store an event, and update submission state. + + This is where we map the NG event domain onto the classic database. The + main differences are that: + + - In the event domain, a submission is a single stream of events, but + in the classic system we create new rows in the submission database + for things like replacements, adding DOIs, and withdrawing papers. + - In the event domain, the only concept of the announced paper is the + paper ID. In the classic submission database, we also have to worry about + the row in the Document database. + + We assume that the submission states passed to this function have the + correct paper ID and version number, if announced. The submission ID on + the event and the before/after states refer to the original classic + submission only. + + Parameters + ---------- + event : :class:`Event` + before : :class:`Submission` + The state of the submission before the event occurred. + after : :class:`Submission` + The state of the submission after the event occurred. + call : list + Items are callables that accept args ``Event, Submission, Submission``. + These are called within the transaction context; if an exception is + raised, the transaction is rolled back. + + """ + # Let the caller determine the transaction scope. + session = current_session() + if event.committed: + raise TransactionFailed('%s already committed', event.event_id) + if event.created is None: + raise ValueError('Event creation timestamp not set') + logger.debug('store event %s', event.event_type) + + doc_id: Optional[int] = None + + # This is the case that we have a new submission. + if before is None: # and isinstance(after, Submission): + dbs = models.Submission(type=models.Submission.NEW_SUBMISSION) + dbs.update_from_submission(after) + this_is_a_new_submission = True + + else: # Otherwise we're making an update for an existing submission. + this_is_a_new_submission = False + + if before.arxiv_id is not None: #: + # After the original submission is announced, a new Document row is + # created. This Document is shared by all subsequent Submission rows. + doc_id = _load_document_id(before.arxiv_id, before.version) + + # From the perspective of the database, a replacement is mainly an + # incremented version number. This requires a new row in the + # database. + if after.version > before.version: + dbs = _create_replacement(doc_id, before.arxiv_id, + after.version, after, event.created) + elif isinstance(event, Rollback) and before.version > 1: + dbs = _delete_replacement(doc_id, before.arxiv_id, + before.version) + + + # Withdrawals also require a new row, and they use the most recent + # version number. + elif isinstance(event, RequestWithdrawal): + dbs = _create_withdrawal(doc_id, event.reason, + before.arxiv_id, after.version, after, + event.created) + elif isinstance(event, RequestCrossList): + dbs = _create_crosslist(doc_id, event.categories, + before.arxiv_id, after.version, after, + event.created) + + # Adding DOIs and citation information (so-called "journal reference") + # also requires a new row. The version number is not incremented. + elif before.is_announced and type(event) in JREFEvents: + dbs = _create_jref(doc_id, before.arxiv_id, after.version, after, + event.created) + + elif isinstance(event, CancelRequest): + dbs = _cancel_request(event, before, after) + + # The submission has been announced. + elif isinstance(before, Submission) and before.arxiv_id is not None: + dbs = _load(paper_id=before.arxiv_id, version=before.version) + _preserve_sticky_hold(dbs, before, after, event) + dbs.update_from_submission(after) + else: + raise TransactionFailed("Something is fishy") + + + # The submission has not yet been announced; we're working with a + # single row. + elif isinstance(before, Submission) and before.submission_id: + dbs = _load(before.submission_id) + + _preserve_sticky_hold(dbs, before, after, event) + dbs.update_from_submission(after) + else: + raise TransactionFailed("Something is fishy") + + db_event = _new_dbevent(event) + session.add(dbs) + session.add(db_event) + + # Make sure that we get a submission ID; note that this # does not commit + # the transaction, just pushes the # SQL that we have generated so far to + # the database # server. + session.flush() + + log.handle(event, before, after) # Create admin log entry. + for func in call: + logger.debug('call %s with event %s', func, event.event_id) + func(event, before, after) + if isinstance(event, AddProposal): + assert before is not None + proposal.add(event, before, after) + + # Attach the database object for the event to the row for the + # submission. + if this_is_a_new_submission: # Update in transaction. + db_event.submission = dbs + else: # Just set the ID directly. + assert before is not None + db_event.submission_id = before.submission_id + + event.committed = True + + # Update the domain event and submission states with the submission ID. + # This should carry forward the original submission ID, even if the + # classic database has several rows for the submission (with different + # IDs). + if this_is_a_new_submission: + event.submission_id = dbs.submission_id + after.submission_id = dbs.submission_id + else: + assert before is not None + event.submission_id = before.submission_id + after.submission_id = before.submission_id + return event, after + + +@retry(ClassicBaseException, tries=3, delay=1) +@handle_operational_errors +def get_titles(since: datetime) -> List[Tuple[int, str, Agent]]: + """Get titles from submissions created on or after a particular date.""" + # TODO: consider making this a param, if we need this function for anything + # else. + STATUSES_TO_CHECK = [ + models.Submission.SUBMITTED, + models.Submission.ON_HOLD, + models.Submission.NEXT_PUBLISH_DAY, + models.Submission.REMOVED, + models.Submission.USER_DELETED, + models.Submission.DELETED_ON_HOLD, + models.Submission.DELETED_PROCESSING, + models.Submission.DELETED_REMOVED, + models.Submission.DELETED_USER_EXPIRED + ] + session = current_session() + q = session.query( + models.Submission.submission_id, + models.Submission.title, + models.Submission.submitter_id, + models.Submission.submitter_email + ) + q = q.filter(models.Submission.status.in_(STATUSES_TO_CHECK)) + q = q.filter(models.Submission.created >= since) + return [ + (submission_id, title, User(native_id=user_id, email=user_email)) + for submission_id, title, user_id, user_email in q.all() + ] + + +# Private functions down here. + +def _load(submission_id: Optional[int] = None, paper_id: Optional[str] = None, + version: Optional[int] = 1, row_type: Optional[str] = None) \ + -> models.Submission: + if row_type is not None: + limit_to = [row_type] + else: + limit_to = [models.Submission.NEW_SUBMISSION, + models.Submission.REPLACEMENT] + session = current_session() + if submission_id is not None: + submission = session.query(models.Submission) \ + .filter(models.Submission.submission_id == submission_id) \ + .filter(models.Submission.type.in_(limit_to)) \ + .one() + elif submission_id is None and paper_id is not None: + submission = session.query(models.Submission) \ + .filter(models.Submission.doc_paper_id == paper_id) \ + .filter(models.Submission.version == version) \ + .filter(models.Submission.type.in_(limit_to)) \ + .order_by(models.Submission.submission_id.desc()) \ + .first() + else: + submission = None + if submission is None: + raise NoSuchSubmission("No submission row matches those parameters") + assert isinstance(submission, models.Submission) + return submission + + +def _cancel_request(event: CancelRequest, before: Submission, + after: Submission) -> models.Submission: + assert event.request_id is not None + request = before.user_requests[event.request_id] + if isinstance(request, WithdrawalRequest): + row_type = models.Submission.WITHDRAWAL + elif isinstance(request, CrossListClassificationRequest): + row_type = models.Submission.CROSS_LIST + dbs = _load(paper_id=before.arxiv_id, version=before.version, + row_type=row_type) + dbs.status = models.Submission.USER_DELETED + return dbs + + +def _load_document_id(paper_id: str, version: int) -> int: + logger.debug('get document ID with %s and %s', paper_id, version) + session = current_session() + document_id = session.query(models.Submission.document_id) \ + .filter(models.Submission.doc_paper_id == paper_id) \ + .filter(models.Submission.version == version) \ + .first() + if document_id is None: + raise NoSuchSubmission("No submission row matches those parameters") + return int(document_id[0]) + + +def _create_replacement(document_id: int, paper_id: str, version: int, + submission: Submission, created: datetime) \ + -> models.Submission: + """ + Create a new replacement submission. + + From the perspective of the database, a replacement is mainly an + incremented version number. This requires a new row in the database. + """ + dbs = models.Submission(type=models.Submission.REPLACEMENT, + document_id=document_id, version=version) + dbs.update_from_submission(submission) + dbs.created = created + dbs.updated = created + dbs.doc_paper_id = paper_id + dbs.status = models.Submission.NOT_SUBMITTED + return dbs + + +def _delete_replacement(document_id: int, paper_id: str, version: int) \ + -> models.Submission: + session = current_session() + dbs = session.query(models.Submission) \ + .filter(models.Submission.doc_paper_id == paper_id) \ + .filter(models.Submission.version == version) \ + .filter(models.Submission.type == models.Submission.REPLACEMENT) \ + .order_by(models.Submission.submission_id.desc()) \ + .first() + dbs.status = models.Submission.USER_DELETED + assert isinstance(dbs, models.Submission) + return dbs + + +def _create_withdrawal(document_id: int, reason: str, paper_id: str, + version: int, submission: Submission, + created: datetime) -> models.Submission: + """ + Create a new withdrawal request. + + Withdrawals also require a new row, and they use the most recent version + number. + """ + dbs = models.Submission(type=models.Submission.WITHDRAWAL, + document_id=document_id, + version=version) + dbs.update_withdrawal(submission, reason, paper_id, version, created) + return dbs + + +def _create_crosslist(document_id: int, categories: List[str], paper_id: str, + version: int, submission: Submission, + created: datetime) -> models.Submission: + """ + Create a new crosslist request. + + Cross list requests also require a new row, and they use the most recent + version number. + """ + dbs = models.Submission(type=models.Submission.CROSS_LIST, + document_id=document_id, + version=version) + dbs.update_cross(submission, categories, paper_id, version, created) + return dbs + + +def _create_jref(document_id: int, paper_id: str, version: int, + submission: Submission, + created: datetime) -> models.Submission: + """ + Create a JREF submission. + + Adding DOIs and citation information (so-called "journal reference") also + requires a new row. The version number is not incremented. + """ + # Try to piggy-back on an existing JREF row. In the classic system, all + # three fields can get updated on the same row. + try: + most_recent_sb = _load(paper_id=paper_id, version=version, + row_type=models.Submission.JOURNAL_REFERENCE) + if most_recent_sb and not most_recent_sb.is_announced(): + most_recent_sb.update_from_submission(submission) + return most_recent_sb + except NoSuchSubmission: + pass + + # Otherwise, create a new JREF row. + dbs = models.Submission(type=models.Submission.JOURNAL_REFERENCE, + document_id=document_id, version=version) + dbs.update_from_submission(submission) + dbs.created = created + dbs.updated = created + dbs.doc_paper_id = paper_id + dbs.status = models.Submission.PROCESSING_SUBMISSION + return dbs + + +def _new_dbevent(event: Event) -> DBEvent: + """Create an event entry in the database.""" + return DBEvent(event_type=event.event_type, + event_id=event.event_id, + event_version=_get_app_version(), + data=asdict(event), + created=event.created, + creator=asdict(event.creator), + proxy=asdict(event.proxy) if event.proxy else None) + + +def _preserve_sticky_hold(dbs: models.Submission, before: Submission, + after: Submission, event: Event) -> None: + if dbs.status != models.Submission.ON_HOLD: + return + if dbs.is_on_hold() and after.status == Submission.WORKING: + dbs.sticky_status = models.Submission.ON_HOLD + + +def _get_app_version() -> str: + return str(get_application_config().get('CORE_VERSION', '0.0.0')) + + +def init_app(app: Flask) -> None: + """Register the SQLAlchemy extension to an application.""" + db.init_app(app) + + @app.teardown_request + def teardown_request(exception: Optional[Exception]) -> None: + if exception is not None: + db.session.rollback() + db.session.remove() + + @app.teardown_appcontext + def teardown_appcontext(*args: Any, **kwargs: Any) -> None: + db.session.rollback() + db.session.remove() + + +def create_all() -> None: + """Create all tables in the database.""" + Base.metadata.create_all(db.engine) + + +def drop_all() -> None: + """Drop all tables in the database.""" + Base.metadata.drop_all(db.engine) + + +def _get_db_submission_rows(submission_id: int) -> List[models.Submission]: + session = current_session() + head = session.query(models.Submission.submission_id, + models.Submission.doc_paper_id) \ + .filter_by(submission_id=submission_id) \ + .subquery() + dbss = list( + session.query(models.Submission) + .filter(or_(models.Submission.submission_id == submission_id, + models.Submission.doc_paper_id == head.c.doc_paper_id)) + .order_by(models.Submission.submission_id.desc()) + ) + if not dbss: + raise NoSuchSubmission('No submission found') + return dbss diff --git a/src/arxiv/submission/services/classic/bootstrap.py b/src/arxiv/submission/services/classic/bootstrap.py new file mode 100644 index 0000000..cf0a593 --- /dev/null +++ b/src/arxiv/submission/services/classic/bootstrap.py @@ -0,0 +1,155 @@ +"""Generate synthetic data for testing and development purposes.""" + +import random +from datetime import datetime +from typing import List, Dict, Any + +from mimesis import Person, Internet, Datetime +from mimesis import config as mimesis_config + +from arxiv import taxonomy +from . import models + +LOCALES = list(mimesis_config.SUPPORTED_LOCALES.keys()) + + +def _get_locale() -> str: + loc: str = LOCALES[random.randint(0, len(LOCALES) - 1)] + return loc + + +def _epoch(t: datetime) -> int: + return int((t - datetime.utcfromtimestamp(0)).total_seconds()) + + +LICENSES: List[Dict[str, Any]] = [ + { + "name": "", + "note": None, + "label": "None of the above licenses apply", + "active": 1, + "sequence": 99 + }, + { + "name": "http://arxiv.org/licenses/assumed-1991-2003/", + "note": "", + "label": "Assumed arXiv.org perpetual, non-exclusive license to" + + " distribute this article for submissions made before" + + " January 2004", + "active": 0, + "sequence": 9 + }, + { + "name": "http://arxiv.org/licenses/nonexclusive-distrib/1.0/", + "note": "(Minimal rights required by arXiv.org. Select this unless" + + " you understand the implications of other licenses.)", + "label": "arXiv.org perpetual, non-exclusive license to distribute" + + " this article", + "active": 1, + "sequence": 1 + }, + { + "name": "http://creativecommons.org/licenses/by-nc-sa/3.0/", + "note": "", + "label": "Creative Commons Attribution-Noncommercial-ShareAlike" + + " license", + "active": 0, + "sequence": 3 + }, + { + "name": "http://creativecommons.org/licenses/by-nc-sa/4.0/", + "note": "", + "label": "Creative Commons Attribution-Noncommercial-ShareAlike" + + " license (CC BY-NC-SA 4.0)", + "active": 1, + "sequence": 7 + }, + { + "name": "http://creativecommons.org/licenses/by-sa/4.0/", + "note": "", + "label": "Creative Commons Attribution-ShareAlike license" + + " (CC BY-SA 4.0)", + "active": 1, + "sequence": 6 + }, + { + "name": "http://creativecommons.org/licenses/by/3.0/", + "note": "", + "label": "Creative Commons Attribution license", + "active": 0, + "sequence": 2 + }, + { + "name": "http://creativecommons.org/licenses/by/4.0/", + "note": "", + "label": "Creative Commons Attribution license (CC BY 4.0)", + "active": 1, + "sequence": 5 + }, + { + "name": "http://creativecommons.org/licenses/publicdomain/", + "note": "(Suitable for US government employees, for example)", + "label": "Creative Commons Public Domain Declaration", + "active": 0, + "sequence": 4 + }, + { + "name": "http://creativecommons.org/publicdomain/zero/1.0/", + "note": "", + "label": "Creative Commons Public Domain Declaration (CC0 1.0)", + "active": 1, + "sequence": 8 + } +] + +POLICY_CLASSES = [ + {"name": "Administrator", "class_id": 1, "description": ""}, + {"name": "Public user", "class_id": 2, "description": ""}, + {"name": "Legacy user", "class_id": 3, "description": ""} +] + + +def categories() -> List[models.CategoryDef]: + """Generate data for current arXiv categories.""" + return [ + models.CategoryDef( + category=category, + name=data['name'], + active=1 + ) for category, data in taxonomy.CATEGORIES.items() + ] + + +def policy_classes() -> List[models.PolicyClass]: + """Generate policy classes.""" + return [models.PolicyClass(**datum) for datum in POLICY_CLASSES] + + +def users(count: int = 500) -> List[models.User]: + """Generate a bunch of random users.""" + _users = [] + for i in range(count): + locale = _get_locale() + person = Person(locale) + net = Internet(locale) + ip_addr = net.ip_v4() + _users.append(models.User( + first_name=person.name(), + last_name=person.surname(), + suffix_name=person.title(), + share_first_name=1, + share_last_name=1, + email=person.email(), + share_email=8, + email_bouncing=0, + policy_class=2, # Public user. + joined_date=_epoch(Datetime(locale).datetime()), + joined_ip_num=ip_addr, + joined_remote_host=ip_addr + )) + return _users + + +def licenses() -> List[models.License]: + """Generate licenses.""" + return [models.License(**datum) for datum in LICENSES] diff --git a/src/arxiv/submission/services/classic/event.py b/src/arxiv/submission/services/classic/event.py new file mode 100644 index 0000000..8b5bf53 --- /dev/null +++ b/src/arxiv/submission/services/classic/event.py @@ -0,0 +1,76 @@ +"""Persistence for NG events in the classic database.""" + +from datetime import datetime +from pytz import UTC + +from sqlalchemy import Column, String, ForeignKey +from sqlalchemy.ext.indexable import index_property +from sqlalchemy.orm import relationship + +# Combining the base DateTime field with a MySQL backend does not support +# fractional seconds. Since we may be creating events only milliseconds apart, +# getting fractional resolution is essential. +from sqlalchemy.dialects.mysql import DATETIME as DateTime + +from ...domain.event import Event, event_factory +from ...domain.agent import User, Client, Agent, System, agent_factory +from .models import Base +from .util import transaction, current_session, FriendlyJSON + + +class DBEvent(Base): # type: ignore + """Database representation of an :class:`.Event`.""" + + __tablename__ = 'event' + + event_id = Column(String(40), primary_key=True) + event_type = Column(String(255)) + event_version = Column(String(20), default='0.0.0') + proxy = Column(FriendlyJSON) + proxy_id = index_property('proxy', 'agent_identifier') + client = Column(FriendlyJSON) + client_id = index_property('client', 'agent_identifier') + + creator = Column(FriendlyJSON) + creator_id = index_property('creator', 'agent_identifier') + + created = Column(DateTime(fsp=6)) + data = Column(FriendlyJSON) + submission_id = Column( + ForeignKey('arXiv_submissions.submission_id'), + index=True + ) + + submission = relationship("Submission") + + def to_event(self) -> Event: + """ + Instantiate an :class:`.Event` using event data from this instance. + + Returns + ------- + :class:`.Event` + + """ + _skip = ['creator', 'proxy', 'client', 'submission_id', 'created', + 'event_type', 'event_version'] + data = { + key: value for key, value in self.data.items() + if key not in _skip + } + data['committed'] = True # Since we're loading from the DB. + return event_factory( + event_type=self.event_type, + creator=agent_factory(**self.creator), + event_version=self.event_version, + proxy=agent_factory(**self.proxy) if self.proxy else None, + client=agent_factory(**self.client) if self.client else None, + submission_id=self.submission_id, + created=self.get_created(), + **data + ) + + def get_created(self) -> datetime: + """Get the UTC-localized creation time for this event.""" + dt: datetime = self.created.replace(tzinfo=UTC) + return dt diff --git a/src/arxiv/submission/services/classic/exceptions.py b/src/arxiv/submission/services/classic/exceptions.py new file mode 100644 index 0000000..8f37c99 --- /dev/null +++ b/src/arxiv/submission/services/classic/exceptions.py @@ -0,0 +1,21 @@ +"""Exceptions raised by :mod:`arxiv.submission.services.classic`.""" + + +class ClassicBaseException(RuntimeError): + """Base for classic service exceptions.""" + + +class NoSuchSubmission(ClassicBaseException): + """A request was made for a submission that does not exist.""" + + +class TransactionFailed(ClassicBaseException): + """Raised when there was a problem committing changes to the database.""" + + +class Unavailable(ClassicBaseException): + """The classic data store is not available.""" + + +class ConsistencyError(ClassicBaseException): + """Attempted to persist stale or inconsistent state.""" diff --git a/src/arxiv/submission/services/classic/interpolate.py b/src/arxiv/submission/services/classic/interpolate.py new file mode 100644 index 0000000..1759f78 --- /dev/null +++ b/src/arxiv/submission/services/classic/interpolate.py @@ -0,0 +1,304 @@ +""" +Inject events from outside the scope of the NG submission system. + +A core concept of the :mod:`arxiv.submission.domain.event` model is that +the state of a submission can be obtained by playing forward all of the +commands/events applied to it. That works when all agents that operate +on submission state are generating commands. The problem that we face in +the short term is that some operations will be performed by legacy components +that don't generate command/event data. + +The objective of the :class:`ClassicEventInterpolator` is to reconcile +NG events/commands with aspects of the classic database that are outside its +current purview. The logic in this module will need to change as the scope +of the NG submission data architecture expands. +""" + +from typing import List, Optional, Dict, Tuple, Any, Type +from datetime import datetime + +from arxiv.base import logging +from arxiv import taxonomy +from . import models +from ...domain.submission import Submission, UserRequest, WithdrawalRequest, \ + CrossListClassificationRequest, Hold +from ...domain.event import Event, SetDOI, SetJournalReference, \ + SetReportNumber, ApplyRequest, RejectRequest, Announce, AddHold, \ + CancelRequest, SetPrimaryClassification, AddSecondaryClassification, \ + SetTitle, SetAbstract, SetComments, SetMSCClassification, \ + SetACMClassification, SetAuthors, Reclassify, ConfirmSourceProcessed + +from ...domain.agent import System, User +from .load import status_from_classic + + +logger = logging.getLogger(__name__) +logger.propagate = False +SYSTEM = System(__name__) + + +class ClassicEventInterpolator: + """Interleaves events with classic data to get the current state.""" + + def __init__(self, current_row: models.Submission, + subsequent_rows: List[models.Submission], + events: List[Event]) -> None: + """Interleave events with classic data to get the current state.""" + self.applied_events: List[Event] = [] + self.current_row: Optional[models.Submission] = current_row + self.db_rows = subsequent_rows + logger.debug("start with current row: %s", self.current_row) + logger.debug("start with subsequent rows: %s", + [(d.type, d.status) for d in self.db_rows]) + self.events = events + self.submission_id = current_row.submission_id + # We always start from the beginning (no submission). + self.submission: Optional[Submission] = None + self.arxiv_id = self.current_row.get_arxiv_id() + + self.requests = { + WithdrawalRequest: 0, + CrossListClassificationRequest: 0 + } + + @property + def next_row(self) -> models.Submission: + """Access the next classic database row for this submission.""" + return self.db_rows[0] + + def _insert_request_event(self, rq_class: Type[UserRequest], + event_class: Type[Event]) -> None: + """Create and apply a request-related event.""" + assert self.submission is not None and self.current_row is not None + logger.debug('insert request event, %s, %s', + rq_class.__name__, event_class.__name__) + # Mypy still chokes on these dataclass params. + event = event_class( # type: ignore + creator=SYSTEM, + created=self.current_row.get_updated(), + committed=True, + request_id=rq_class.generate_request_id(self.submission) + ) + self._apply(event) + # self.current_row.get_created(), + # rq_class.__name__, + # self.current_row.get_submitter() + # ) + + def _current_row_preceeds_event(self, event: Event) -> bool: + assert self.current_row is not None and event.created is not None + delta = self.current_row.get_updated() - event.created + # Classic lacks millisecond precision. + return (delta).total_seconds() < -1 + + def _should_advance_to_next_row(self, event: Event) -> bool: + if self._there_are_rows_remaining(): + assert self.next_row is not None and event.created is not None + return bool(self.next_row.get_created() <= event.created) + return False + + def _there_are_rows_remaining(self) -> bool: + return len(self.db_rows) > 0 + + def _advance_to_next_row(self) -> None: + assert self.submission is not None and self.current_row is not None + if self.current_row.is_withdrawal(): + self.requests[WithdrawalRequest] += 1 + if self.current_row.is_crosslist(): + self.requests[CrossListClassificationRequest] += 1 + try: + self.current_row = self.db_rows.pop(0) + except IndexError: + self.current_row = None + + def _can_inject_from_current_row(self) -> bool: + assert self.current_row is not None + return bool( + self.current_row.version == 1 + or (self.current_row.is_jref() + and not self.current_row.is_deleted()) + or self.current_row.is_withdrawal() + or self.current_row.is_crosslist() + or (self.current_row.is_new_version() + and not self.current_row.is_deleted()) + ) + + def _should_backport(self, event: Event) -> bool: + """Evaluate if this event be applied to the last announced version.""" + assert self.submission is not None and self.current_row is not None + return bool( + type(event) in [SetDOI, SetJournalReference, SetReportNumber] + and self.submission.versions + and self.submission.version + == self.submission.versions[-1].version + ) + + def _inject_from_current_row(self) -> None: + assert self.current_row is not None + if self.current_row.is_new_version(): + # Apply any holds created in the admin or moderation system. + if self.current_row.status == models.Submission.ON_HOLD: + self._inject(AddHold, hold_type=Hold.Type.PATCH) + + # TODO: these need some explicit event/command representations. + elif self.submission is not None: + if status_from_classic(self.current_row.status) \ + == Submission.SCHEDULED: + self.submission.status = Submission.SCHEDULED + elif status_from_classic(self.current_row.status) \ + == Submission.DELETED: + self.submission.status = Submission.DELETED + elif status_from_classic(self.current_row.status) \ + == Submission.ERROR: + self.submission.status = Submission.ERROR + + self._inject_primary_if_changed() + self._inject_secondaries_if_changed() + self._inject_metadata_if_changed() + self._inject_jref_if_changed() + + if self.current_row.must_process == 0: + self._inject(ConfirmSourceProcessed) + + if self.current_row.is_announced(): + self._inject(Announce, arxiv_id=self.arxiv_id) + elif self.current_row.is_jref(): + self._inject_jref_if_changed() + elif self.current_row.is_withdrawal(): + self._inject_request_if_changed(WithdrawalRequest) + elif self.current_row.is_crosslist(): + self._inject_request_if_changed(CrossListClassificationRequest) + + def _inject_primary_if_changed(self) -> None: + """Inject primary classification event if a change has occurred.""" + assert self.current_row is not None + primary = self.current_row.primary_classification + if primary and self.submission is not None: + if primary.category != self.submission.primary_category: + self._inject(Reclassify, category=primary.category) + + def _inject_secondaries_if_changed(self) -> None: + """Inject secondary classification events if a change has occurred.""" + assert self.current_row is not None + # Add any missing secondaries. + for dbc in self.current_row.categories: + if (self.submission is not None + and dbc.category not in self.submission.secondary_categories + and not dbc.is_primary): + + self._inject(AddSecondaryClassification, + category=taxonomy.Category(dbc.category)) + + def _inject_metadata_if_changed(self) -> None: + assert self.submission is not None and self.current_row is not None + row = self.current_row # For readability, below. + if self.submission.metadata.title != row.title: + self._inject(SetTitle, title=row.title) + if self.submission.metadata.abstract != row.abstract: + self._inject(SetAbstract, abstract=row.abstract) + if self.submission.metadata.comments != row.comments: + self._inject(SetComments, comments=row.comments) + if self.submission.metadata.msc_class != row.msc_class: + self._inject(SetMSCClassification, msc_class=row.msc_class) + if self.submission.metadata.acm_class != row.acm_class: + self._inject(SetACMClassification, acm_class=row.acm_class) + if self.submission.metadata.authors_display != row.authors: + self._inject(SetAuthors, authors_display=row.authors) + + def _inject_jref_if_changed(self) -> None: + assert self.submission is not None and self.current_row is not None + row = self.current_row # For readability, below. + if self.submission.metadata.doi != self.current_row.doi: + self._inject(SetDOI, doi=row.doi) + if self.submission.metadata.journal_ref != row.journal_ref: + self._inject(SetJournalReference, journal_ref=row.journal_ref) + if self.submission.metadata.report_num != row.report_num: + self._inject(SetReportNumber, report_num=row.report_num) + + def _inject_request_if_changed(self, req_type: Type[UserRequest]) -> None: + """ + Update a request on the submission, if status changed. + + We will assume that the request itself originated in the NG system, + so we will NOT create a new request. + """ + assert self.submission is not None and self.current_row is not None + request_id = req_type.generate_request_id(self.submission, + self.requests[req_type]) + if self.current_row.is_announced(): + self._inject(ApplyRequest, request_id=request_id) + elif self.current_row.is_deleted(): + self._inject(CancelRequest, request_id=request_id) + elif self.current_row.is_rejected(): + self._inject(RejectRequest, request_id=request_id) + + def _inject(self, event_type: Type[Event], **data: Any) -> None: + assert self.submission is not None and self.current_row is not None + created = self.current_row.get_updated() + logger.debug('inject %s', event_type.NAME) + event = event_type(creator=SYSTEM, # type: ignore + created=created, # Mypy has a hard time with these + committed=True, # dataclass params. + submission_id=self.submission_id, + **data) + self._apply(event) + + def _apply(self, event: Event) -> None: + self.submission = event.apply(self.submission) + self.applied_events.append(event) + + def _backport_event(self, event: Event) -> None: + assert self.submission is not None + self.submission.versions[-1] = \ + event.apply(self.submission.versions[-1]) + + def get_submission_state(self) -> Tuple[Submission, List[Event]]: + """ + Get the current state of the :class:`Submission`. + + This is effectively memoized. + + Returns + ------- + :class:`.domain.submission.Submission` + The most recent state of the submission given the provided events + and database rows. + list + Items are :class:`.Event` instances applied to generate the + returned state. This may include events inferred and interpolated + from the classic database, not passed in the original set of + events. + + """ + for event in self.events: + # As we go, look for moments where a new row in the legacy + # submission table was created. + if self._current_row_preceeds_event(event) \ + or self._should_advance_to_next_row(event): + # If we find one, patch the domain submission from the + # preceding row, and load the next row. We want to do this + # before projecting the event, since we are inferring that the + # event occurred after a change was made via the legacy system. + if self._can_inject_from_current_row(): + self._inject_from_current_row() + + if self._should_advance_to_next_row(event): + self._advance_to_next_row() + + self._apply(event) # Now project the event. + + # Backport JREFs to the announced version to which they apply. + if self._should_backport(event): + self._backport_event(event) + + # Finally, patch the submission with any remaining changes that may + # have occurred via the legacy system. + while self.current_row is not None: + if self._can_inject_from_current_row(): + self._inject_from_current_row() + self._advance_to_next_row() + + assert self.submission is not None + logger.debug('done; submission in state %s with %i events', + self.submission.status, len(self.applied_events)) + return self.submission, self.applied_events diff --git a/src/arxiv/submission/services/classic/load.py b/src/arxiv/submission/services/classic/load.py new file mode 100644 index 0000000..7df707b --- /dev/null +++ b/src/arxiv/submission/services/classic/load.py @@ -0,0 +1,226 @@ +"""Supports loading :class:`.Submission` directly from classic data.""" + +import copy +from itertools import groupby +from operator import attrgetter +from typing import List, Optional, Iterable, Dict + +from arxiv.base import logging +from arxiv.license import LICENSES + +from ... import domain +from . import models +from .patch import patch_withdrawal, patch_jref, patch_cross, patch_hold + +logger = logging.getLogger(__name__) +logger.propagate = False + + +def load(rows: Iterable[models.Submission]) -> Optional[domain.Submission]: + """ + Load a submission entirely from its classic database rows. + + Parameters + ---------- + rows : list + Items are :class:`.models.Submission` rows loaded from the classic + database belonging to a single arXiv e-print/submission group. + + Returns + ------- + :class:`.domain.Submission` or ``None`` + Aggregated submission object (with ``.versions``). If there is no + representation (e.g. all rows are deleted), returns ``None``. + + """ + versions: List[domain.Submission] = [] + submission_id: Optional[int] = None + + # We want to work within versions, and (secondarily) in order of creation + # time. + rows = sorted(rows, key=lambda o: o.version) + logger.debug('Load from rows %s', [r.submission_id for r in rows]) + for version, version_rows in groupby(rows, key=attrgetter('version')): + # Creation time isn't all that precise in the classic database, so + # we'll use submission ID instead. + these_version_rows = sorted([v for v in version_rows], + key=lambda o: o.submission_id) + logger.debug('Version %s: %s', version, version_rows) + # We use the original ID to track the entire lifecycle of the + # submission in NG. + if version == 1: + submission_id = these_version_rows[0].submission_id + logger.debug('Submission ID: %s', submission_id) + + # Find the creation row. There may be some false starts that have been + # deleted, so we need to advance to the first non-deleted 'new' or + # 'replacement' row. + version_submission: Optional[domain.Submission] = None + while version_submission is None: + try: + row = these_version_rows.pop(0) + except IndexError: + break + if row.is_new_version() and \ + (row.type == row.NEW_SUBMISSION or not row.is_deleted()): + # Get the initial state of the version. + version_submission = to_submission(row, submission_id) + logger.debug('Got initial state: %s', version_submission) + + if version_submission is None: + logger.debug('Nothing to work with for this version') + continue + + # If this is not the first version, carry forward any requests. + if len(versions) > 0: + logger.debug('Bring user_requests forward from last version') + version_submission.user_requests.update(versions[-1].user_requests) + + for row in these_version_rows: # Remaining rows, since we popped the others. + # We are treating JREF submissions as though there is no approval + # process; so we can just ignore deleted JREF rows. + if row.is_jref() and not row.is_deleted(): + # This should update doi, journal_ref, report_num. + version_submission = patch_jref(version_submission, row) + # For withdrawals and cross-lists, we want to get data from + # deleted rows since we keep track of all requests in the NG + # submission. + elif row.is_withdrawal(): + # This should update the reason_for_withdrawal (if applied), + # and add a WithdrawalRequest to user_requests. + version_submission = patch_withdrawal(version_submission, row) + elif row.is_crosslist(): + # This should update the secondary classifications (if applied) + # and add a CrossListClassificationRequest to user_requests. + version_submission = patch_cross(version_submission, row) + + # We want hold information represented as a Hold on the submission + # object, not just the status. + if version_submission.is_on_hold: + version_submission = patch_hold(version_submission, row) + versions.append(version_submission) + + if not versions: + return None + submission = copy.deepcopy(versions[-1]) + submission.versions = [ver for ver in versions if ver and ver.is_announced] + return submission + + +def to_submission(row: models.Submission, + submission_id: Optional[int] = None) -> domain.Submission: + """ + Generate a representation of submission state from a DB instance. + + Parameters + ---------- + row : :class:`.models.Submission` + Database row representing a :class:`.domain.submission.Submission`. + submission_id : int or None + If provided the database value is overridden when setting + :attr:`domain.Submission.submission_id`. + + Returns + ------- + :class:`.domain.submission.Submission` + + """ + status = status_from_classic(row.status) + primary = row.primary_classification + if row.submitter is None: + submitter = domain.User(native_id=row.submitter_id, + email=row.submitter_email) + else: + submitter = row.get_submitter() + if submission_id is None: + submission_id = row.submission_id + + license: Optional[domain.License] = None + if row.license: + label = LICENSES[row.license]['label'] + license = domain.License(uri=row.license, name=label) + + primary_clsn: Optional[domain.Classification] = None + if primary and primary.category: + _category = domain.Category(primary.category) + primary_clsn = domain.Classification(category=_category) + secondary_clsn = [ + domain.Classification(category=domain.Category(db_cat.category)) + for db_cat in row.categories if not db_cat.is_primary + ] + + content: Optional[domain.SubmissionContent] = None + if row.package: + if row.package.startswith('fm://'): + identifier, checksum = row.package.split('://', 1)[1].split('@', 1) + else: + identifier = row.package + checksum = "" + source_format = domain.SubmissionContent.Format(row.source_format) + content = domain.SubmissionContent(identifier=identifier, + compressed_size=0, + uncompressed_size=row.source_size, + checksum=checksum, + source_format=source_format) + + assert status is not None + submission = domain.Submission( + submission_id=submission_id, + creator=submitter, + owner=submitter, + status=status, + created=row.get_created(), + updated=row.get_updated(), + source_content=content, + submitter_is_author=bool(row.is_author), + submitter_accepts_policy=bool(row.agree_policy), + submitter_contact_verified=bool(row.userinfo), + is_source_processed=not bool(row.must_process), + submitter_confirmed_preview=bool(row.viewed), + metadata=domain.SubmissionMetadata(title=row.title, + abstract=row.abstract, + comments=row.comments, + report_num=row.report_num, + doi=row.doi, + msc_class=row.msc_class, + acm_class=row.acm_class, + journal_ref=row.journal_ref), + license=license, + primary_classification=primary_clsn, + secondary_classification=secondary_clsn, + arxiv_id=row.doc_paper_id, + version=row.version + ) + if row.sticky_status == row.ON_HOLD or row.status == row.ON_HOLD: + submission = patch_hold(submission, row) + elif row.is_withdrawal(): + submission = patch_withdrawal(submission, row) + elif row.is_crosslist(): + submission = patch_cross(submission, row) + return submission + + +def status_from_classic(classic_status: int) -> Optional[str]: + """Map classic status codes to domain submission status.""" + return STATUS_MAP.get(classic_status) + + +# Map classic status to Submission domain status. +STATUS_MAP: Dict[int, str] = { + models.Submission.NOT_SUBMITTED: domain.Submission.WORKING, + models.Submission.SUBMITTED: domain.Submission.SUBMITTED, + models.Submission.ON_HOLD: domain.Submission.SUBMITTED, + models.Submission.NEXT_PUBLISH_DAY: domain.Submission.SCHEDULED, + models.Submission.PROCESSING: domain.Submission.SCHEDULED, + models.Submission.PROCESSING_SUBMISSION: domain.Submission.SCHEDULED, + models.Submission.NEEDS_EMAIL: domain.Submission.SCHEDULED, + models.Submission.ANNOUNCED: domain.Submission.ANNOUNCED, + models.Submission.DELETED_ANNOUNCED: domain.Submission.ANNOUNCED, + models.Submission.USER_DELETED: domain.Submission.DELETED, + models.Submission.DELETED_EXPIRED: domain.Submission.DELETED, + models.Submission.DELETED_ON_HOLD: domain.Submission.DELETED, + models.Submission.DELETED_PROCESSING: domain.Submission.DELETED, + models.Submission.DELETED_REMOVED: domain.Submission.DELETED, + models.Submission.DELETED_USER_EXPIRED: domain.Submission.DELETED, + models.Submission.ERROR_STATE: domain.Submission.ERROR +} diff --git a/src/arxiv/submission/services/classic/log.py b/src/arxiv/submission/services/classic/log.py new file mode 100644 index 0000000..c467b7e --- /dev/null +++ b/src/arxiv/submission/services/classic/log.py @@ -0,0 +1,141 @@ +"""Interface to the classic admin log.""" + +from typing import Optional, Iterable, Dict, Callable, List + +from . import models, util +from ...domain.event import Event, UnFinalizeSubmission, AcceptProposal, \ + AddSecondaryClassification, AddMetadataFlag, AddContentFlag, \ + AddClassifierResults +from ...domain.annotation import ClassifierResults +from ...domain.submission import Submission +from ...domain.agent import Agent, System +from ...domain.flag import MetadataFlag, ContentFlag + + +def log_unfinalize(event: Event, before: Optional[Submission], + after: Submission) -> None: + """Create a log entry when a user pulls their submission for changes.""" + assert isinstance(event, UnFinalizeSubmission) + admin_log(event.creator.username, "unfinalize", + "user has pulled submission for editing", + username=event.creator.username, + hostname=event.creator.hostname, + submission_id=after.submission_id, + paper_id=after.arxiv_id) + + +def log_accept_system_cross(event: Event, before: Optional[Submission], + after: Submission) -> None: + """Create a log entry when a system cross is accepted.""" + assert isinstance(event, AcceptProposal) and event.proposal_id is not None + proposal = after.proposals[event.proposal_id] + if type(event.creator) is System: + if proposal.proposed_event_type is AddSecondaryClassification: + category = proposal.proposed_event_data["category"] + admin_log(event.creator.username, "admin comment", + f"Added {category} as secondary: {event.comment}", + username="system", + submission_id=after.submission_id, + paper_id=after.arxiv_id) + + +def log_stopwords(event: Event, before: Optional[Submission], + after: Submission) -> None: + """Create a log entry when there is a problem with stopword content.""" + assert isinstance(event, AddContentFlag) + if event.flag_type is ContentFlag.FlagType.LOW_STOP: + admin_log(event.creator.username, + "admin comment", + event.comment if event.comment is not None else "", + username="system", + submission_id=after.submission_id, + paper_id=after.arxiv_id) + + +def log_classifier_failed(event: Event, before: Optional[Submission], + after: Submission) -> None: + """Create a log entry when the classifier returns no suggestions.""" + assert isinstance(event, AddClassifierResults) + if not event.results: + admin_log(event.creator.username, "admin comment", + "Classifier failed to return results for submission", + username="system", + submission_id=after.submission_id, + paper_id=after.arxiv_id) + + +Callback = Callable[[Event, Optional[Submission], Submission], None] + +ON_EVENT: Dict[type, List[Callback]] = { + UnFinalizeSubmission: [log_unfinalize], + AcceptProposal: [log_accept_system_cross], + AddContentFlag: [log_stopwords] +} +"""Logging functions to call when an event is comitted.""" + + +def handle(event: Event, before: Optional[Submission], + after: Submission) -> None: + """ + Generate an admin log entry for an event that is being committed. + + Looks for a logging function in :const:`.ON_EVENT` and, if found, calls it + with the passed parameters. + + Parameters + ---------- + event : :class:`event.Event` + The event being committed. + before : :class:`.domain.submission.Submission` + State of the submission before the event. + after : :class:`.domain.submission.Submission` + State of the submission after the event. + + """ + if type(event) in ON_EVENT: + for callback in ON_EVENT[type(event)]: + callback(event, before, after) + + +def admin_log(program: str, command: str, text: str, notify: bool = False, + username: Optional[str] = None, + hostname: Optional[str] = None, + submission_id: Optional[int] = None, + paper_id: Optional[str] = None, + document_id: Optional[int] = None) -> models.AdminLogEntry: + """ + Add an entry to the admin log. + + Parameters + ---------- + program : str + Name of the application generating the log entry. + command : str + Name of the command generating the log entry. + text : str + Content of the admin log entry. + notify : bool + username : str + hostname : str + Hostname or IP address of the client. + submission_id : int + paper_id : str + document_id : int + + """ + if paper_id is None and submission_id is not None: + paper_id = f'submit/{submission_id}' + with util.transaction() as session: + entry = models.AdminLogEntry( + paper_id=paper_id, + username=username, + host=hostname, + program=program, + command=command, + logtext=text, + document_id=document_id, + submission_id=submission_id, + notify=notify + ) + session.add(entry) + return entry diff --git a/src/arxiv/submission/services/classic/models.py b/src/arxiv/submission/services/classic/models.py new file mode 100644 index 0000000..e8b462d --- /dev/null +++ b/src/arxiv/submission/services/classic/models.py @@ -0,0 +1,909 @@ +"""SQLAlchemy ORM classes for the classic database.""" + +import json +from typing import Optional, List, Any +from datetime import datetime +from pytz import UTC +from sqlalchemy import Column, Date, DateTime, Enum, ForeignKey, Text, text, \ + ForeignKeyConstraint, Index, Integer, SmallInteger, String, Table +from sqlalchemy.orm import relationship, joinedload, backref +from sqlalchemy.ext.declarative import declarative_base + +from arxiv.base import logging +from arxiv.license import LICENSES +from arxiv import taxonomy + +from ... import domain +from .util import transaction + +Base = declarative_base() + +logger = logging.getLogger(__name__) + + +class Submission(Base): # type: ignore + """Represents an arXiv submission.""" + + __tablename__ = 'arXiv_submissions' + + # Pre-moderation stages; these are tied to the classic submission UI. + NEW = 0 + STARTED = 1 + FILES_ADDED = 2 + PROCESSED = 3 + METADATA_ADDED = 4 + SUBMITTED = 5 + STAGES = [NEW, STARTED, FILES_ADDED, PROCESSED, METADATA_ADDED, SUBMITTED] + + # Submission status; this describes where the submission is in the + # publication workflow. + NOT_SUBMITTED = 0 # Working. + SUBMITTED = 1 # Enqueued for moderation, to be scheduled. + ON_HOLD = 2 + UNUSED = 3 + NEXT_PUBLISH_DAY = 4 + """Scheduled for the next publication cycle.""" + PROCESSING = 5 + """Scheduled for today.""" + NEEDS_EMAIL = 6 + """Announced, not yet announced.""" + + ANNOUNCED = 7 + DELETED_ANNOUNCED = 27 + """Announced and files expired.""" + + PROCESSING_SUBMISSION = 8 + REMOVED = 9 # This is "rejected". + + USER_DELETED = 10 + ERROR_STATE = 19 + """There was a problem validating the submission during publication.""" + + DELETED_EXPIRED = 20 + """Was working but expired.""" + DELETED_ON_HOLD = 22 + DELETED_PROCESSING = 25 + + DELETED_REMOVED = 29 + DELETED_USER_EXPIRED = 30 + """User deleted and files expired.""" + + DELETED = ( + USER_DELETED, DELETED_ON_HOLD, DELETED_PROCESSING, + DELETED_REMOVED, DELETED_USER_EXPIRED, DELETED_EXPIRED + ) + + NEW_SUBMISSION = 'new' + REPLACEMENT = 'rep' + JOURNAL_REFERENCE = 'jref' + WITHDRAWAL = 'wdr' + CROSS_LIST = 'cross' + WITHDRAWN_FORMAT = 'withdrawn' + + submission_id = Column(Integer, primary_key=True) + + type = Column(String(8), index=True) + """Submission type (e.g. ``new``, ``jref``, ``cross``).""" + + document_id = Column( + ForeignKey('arXiv_documents.document_id', + ondelete='CASCADE', + onupdate='CASCADE'), + index=True + ) + doc_paper_id = Column(String(20), index=True) + + sword_id = Column(ForeignKey('arXiv_tracking.sword_id'), index=True) + userinfo = Column(Integer, server_default=text("'0'")) + is_author = Column(Integer, nullable=False, server_default=text("'0'")) + agree_policy = Column(Integer, server_default=text("'0'")) + viewed = Column(Integer, server_default=text("'0'")) + stage = Column(Integer, server_default=text("'0'")) + submitter_id = Column( + ForeignKey('tapir_users.user_id', ondelete='CASCADE', + onupdate='CASCADE'), + index=True + ) + submitter_name = Column(String(64)) + submitter_email = Column(String(64)) + created = Column(DateTime, default=lambda: datetime.now(UTC)) + updated = Column(DateTime, onupdate=lambda: datetime.now(UTC)) + status = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + sticky_status = Column(Integer) + """ + If the submission goes out of queue (e.g. submitter makes changes), + this status should be applied when the submission is re-finalized + (goes back into queue, comes out of working status). + """ + + must_process = Column(Integer, server_default=text("'1'")) + submit_time = Column(DateTime) + release_time = Column(DateTime) + + source_size = Column(Integer, server_default=text("'0'")) + source_format = Column(String(12)) + """Submission content type (e.g. ``pdf``, ``tex``, ``pdftex``).""" + source_flags = Column(String(12)) + + allow_tex_produced = Column(Integer, server_default=text("'0'")) + """Whether to allow a TeX-produced PDF.""" + + package = Column(String(255), nullable=False, server_default=text("''")) + """Path (on disk) to the submission package (tarball, PDF).""" + + is_oversize = Column(Integer, server_default=text("'0'")) + + has_pilot_data = Column(Integer) + is_withdrawn = Column(Integer, nullable=False, server_default=text("'0'")) + title = Column(Text) + authors = Column(Text) + comments = Column(Text) + proxy = Column(String(255)) + report_num = Column(Text) + msc_class = Column(String(255)) + acm_class = Column(String(255)) + journal_ref = Column(Text) + doi = Column(String(255)) + abstract = Column(Text) + license = Column(ForeignKey('arXiv_licenses.name', onupdate='CASCADE'), + index=True) + version = Column(Integer, nullable=False, server_default=text("'1'")) + + is_ok = Column(Integer, index=True) + + admin_ok = Column(Integer) + """Used by administrators for reporting/bookkeeping.""" + + remote_addr = Column(String(16), nullable=False, server_default=text("''")) + remote_host = Column(String(255), nullable=False, + server_default=text("''")) + rt_ticket_id = Column(Integer, index=True) + auto_hold = Column(Integer, server_default=text("'0'")) + """Should be placed on hold when submission comes out of working status.""" + + document = relationship('Document') + arXiv_license = relationship('License') + submitter = relationship('User') + sword = relationship('Tracking') + categories = relationship('SubmissionCategory', + back_populates='submission', lazy='joined', + cascade="all, delete-orphan") + + def get_submitter(self) -> domain.User: + """Generate a :class:`.User` representing the submitter.""" + extra = {} + if self.submitter: + extra.update(dict(forename=self.submitter.first_name, + surname=self.submitter.last_name, + suffix=self.submitter.suffix_name)) + return domain.User(native_id=self.submitter_id, + email=self.submitter_email, **extra) + + + WDR_DELIMETER = '. Withdrawn: ' + + def get_withdrawal_reason(self) -> Optional[str]: + """Extract the withdrawal reason from the comments field.""" + if Submission.WDR_DELIMETER not in self.comments: + return None + return str(self.comments.split(Submission.WDR_DELIMETER, 1)[1]) + + def update_withdrawal(self, submission: domain.Submission, reason: str, + paper_id: str, version: int, + created: datetime) -> None: + """Update withdrawal request information in the database.""" + self.update_from_submission(submission) + self.created = created + self.updated = created + self.doc_paper_id = paper_id + self.status = Submission.PROCESSING_SUBMISSION + reason = f"{Submission.WDR_DELIMETER}{reason}" + self.comments = self.comments.rstrip('. ') + reason + + def update_cross(self, submission: domain.Submission, + categories: List[str], paper_id: str, version: int, + created: datetime) -> None: + """Update cross-list request information in the database.""" + self.update_from_submission(submission) + self.created = created + self.updated = created + self.doc_paper_id = paper_id + self.status = Submission.PROCESSING_SUBMISSION + for category in categories: + self.categories.append( + SubmissionCategory(submission_id=self.submission_id, + category=category, is_primary=0)) + + def update_from_submission(self, submission: domain.Submission) -> None: + """Update this database object from a :class:`.domain.submission.Submission`.""" + if self.is_announced(): # Avoid doing anything. to be safe. + return + + self.submitter_id = submission.creator.native_id + self.submitter_name = submission.creator.name + self.submitter_email = submission.creator.email + self.is_author = 1 if submission.submitter_is_author else 0 + self.agree_policy = 1 if submission.submitter_accepts_policy else 0 + self.userinfo = 1 if submission.submitter_contact_verified else 0 + self.viewed = 1 if submission.submitter_confirmed_preview else 0 + self.updated = submission.updated + self.title = submission.metadata.title + self.abstract = submission.metadata.abstract + self.authors = submission.metadata.authors_display + self.comments = submission.metadata.comments + self.report_num = submission.metadata.report_num + self.doi = submission.metadata.doi + self.msc_class = submission.metadata.msc_class + self.acm_class = submission.metadata.acm_class + self.journal_ref = submission.metadata.journal_ref + + self.version = submission.version # Numeric version. + self.doc_paper_id = submission.arxiv_id # arXiv canonical ID. + + # The document ID is a legacy concept, and not replicated in the NG + # data model. So we need to grab it from the arXiv_documents table + # using the doc_paper_id. + if self.doc_paper_id and not self.document_id: + doc = _load_document(paper_id=self.doc_paper_id) + self.document_id = doc.document_id + + if submission.license: + self.license = submission.license.uri + + if submission.source_content is not None: + self.source_size = submission.source_content.uncompressed_size + if submission.source_content.source_format is not None: + self.source_format = \ + submission.source_content.source_format.value + else: + self.source_format = None + self.package = (f'fm://{submission.source_content.identifier}' + f'@{submission.source_content.checksum}') + + if submission.is_source_processed: + self.must_process = 0 + else: + self.must_process = 1 + + # Not submitted -> Submitted. + if submission.is_finalized \ + and self.status in [Submission.NOT_SUBMITTED, None]: + self.status = Submission.SUBMITTED + self.submit_time = submission.updated + # Delete. + elif submission.is_deleted: + self.status = Submission.USER_DELETED + elif submission.is_on_hold: + self.status = Submission.ON_HOLD + # Unsubmit. + elif self.status is None or self.status <= Submission.ON_HOLD: + if not submission.is_finalized: + self.status = Submission.NOT_SUBMITTED + + if submission.primary_classification: + self._update_primary(submission) + self._update_secondaries(submission) + self._update_submitter(submission) + + # We only want to set the creation datetime on the initial row. + if self.version == 1 and self.type == Submission.NEW_SUBMISSION: + self.created = submission.created + + @property + def primary_classification(self) -> Optional['Category']: + """Get the primary classification for this submission.""" + categories = [ + db_cat for db_cat in self.categories if db_cat.is_primary == 1 + ] + try: + cat: Category = categories[0] + except IndexError: + return None + return cat + + def get_arxiv_id(self) -> Optional[str]: + """Get the arXiv identifier for this submission.""" + if not self.document: + return None + paper_id: Optional[str] = self.document.paper_id + return paper_id + + def get_created(self) -> datetime: + """Get the UTC-localized creation datetime.""" + dt: datetime = self.created.replace(tzinfo=UTC) + return dt + + def get_updated(self) -> datetime: + """Get the UTC-localized updated datetime.""" + dt: datetime = self.updated.replace(tzinfo=UTC) + return dt + + def is_working(self) -> bool: + return bool(self.status == self.NOT_SUBMITTED) + + def is_announced(self) -> bool: + return bool(self.status in [self.ANNOUNCED, self.DELETED_ANNOUNCED]) + + def is_active(self) -> bool: + return bool(not self.is_announced() and not self.is_deleted()) + + def is_rejected(self) -> bool: + return bool(self.status == self.REMOVED) + + def is_finalized(self) -> bool: + return bool(self.status > self.WORKING and not self.is_deleted()) + + def is_deleted(self) -> bool: + return bool(self.status in self.DELETED) + + def is_on_hold(self) -> bool: + return bool(self.status == self.ON_HOLD) + + def is_new_version(self) -> bool: + """Indicate whether this row represents a new version.""" + return bool(self.type in [self.NEW_SUBMISSION, self.REPLACEMENT]) + + def is_withdrawal(self) -> bool: + return bool(self.type == self.WITHDRAWAL) + + def is_crosslist(self) -> bool: + return bool(self.type == self.CROSS_LIST) + + def is_jref(self) -> bool: + return bool(self.type == self.JOURNAL_REFERENCE) + + @property + def secondary_categories(self) -> List[str]: + """Category names from this submission's secondary classifications.""" + return [c.category for c in self.categories if c.is_primary == 0] + + def _update_submitter(self, submission: domain.Submission) -> None: + """Update submitter information on this row.""" + self.submitter_id = submission.creator.native_id + self.submitter_email = submission.creator.email + + def _update_primary(self, submission: domain.Submission) -> None: + """Update primary classification on this row.""" + assert submission.primary_classification is not None + primary_category = submission.primary_classification.category + cur_primary = self.primary_classification + + if cur_primary and cur_primary.category != primary_category: + self.categories.remove(cur_primary) + self.categories.append( + SubmissionCategory(submission_id=self.submission_id, + category=primary_category) + ) + elif cur_primary is None and primary_category: + self.categories.append( + SubmissionCategory( + submission_id=self.submission_id, + category=primary_category, + is_primary=1 + ) + ) + + def _update_secondaries(self, submission: domain.Submission) -> None: + """Update secondary classifications on this row.""" + # Remove any categories that have been removed from the Submission. + for db_cat in self.categories: + if db_cat.is_primary == 1: + continue + if db_cat.category not in submission.secondary_categories: + self.categories.remove(db_cat) + + # Add any new secondaries + for cat in submission.secondary_classification: + if cat.category not in self.secondary_categories: + self.categories.append( + SubmissionCategory( + submission_id=self.submission_id, + category=cat.category, + is_primary=0 + ) + ) + + +class License(Base): # type: ignore + """Licenses available for submissions.""" + + __tablename__ = 'arXiv_licenses' + + name = Column(String(255), primary_key=True) + """This is the URI of the license.""" + + label = Column(String(255)) + """Display label for the license.""" + + active = Column(Integer, server_default=text("'1'")) + """Only offer licenses with active=1.""" + + note = Column(String(255)) + sequence = Column(Integer) + + +class CategoryDef(Base): # type: ignore + """Classification categories available for submissions.""" + + __tablename__ = 'arXiv_category_def' + + category = Column(String(32), primary_key=True) + name = Column(String(255)) + active = Column(Integer, server_default=text("'1'")) + + +class SubmissionCategory(Base): # type: ignore + """Classification relation for submissions.""" + + __tablename__ = 'arXiv_submission_category' + + submission_id = Column( + ForeignKey('arXiv_submissions.submission_id', + ondelete='CASCADE', onupdate='CASCADE'), + primary_key=True, + nullable=False, + index=True + ) + category = Column( + ForeignKey('arXiv_category_def.category'), + primary_key=True, + nullable=False, + index=True, + server_default=text("''") + ) + is_primary = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + is_published = Column(Integer, index=True, server_default=text("'0'")) + + # category_def = relationship('CategoryDef') + submission = relationship('Submission', back_populates='categories') + + +class Document(Base): # type: ignore + """ + Represents an announced arXiv paper. + + This is here so that we can look up the arXiv ID after a submission is + announced. + """ + + __tablename__ = 'arXiv_documents' + + document_id = Column(Integer, primary_key=True) + paper_id = Column(String(20), nullable=False, unique=True, + server_default=text("''")) + title = Column(String(255), nullable=False, index=True, + server_default=text("''")) + authors = Column(Text) + """Canonical author string.""" + + dated = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + + primary_subject_class = Column(String(16)) + + created = Column(DateTime) + + submitter_email = Column(String(64), nullable=False, index=True, + server_default=text("''")) + submitter_id = Column(ForeignKey('tapir_users.user_id'), index=True) + submitter = relationship('User') + + @property + def dated_datetime(self) -> datetime: + """Return the created time as a datetime.""" + return datetime.utcfromtimestamp(self.dated).replace(tzinfo=UTC) + + +class DocumentCategory(Base): # type: ignore + """Relation between announced arXiv papers and their classifications.""" + + __tablename__ = 'arXiv_document_category' + + document_id = Column( + ForeignKey('arXiv_documents.document_id', ondelete='CASCADE'), + primary_key=True, + nullable=False, + index=True, + server_default=text("'0'") + ) + category = Column( + ForeignKey('arXiv_category_def.category'), + primary_key=True, + nullable=False, + index=True + ) + """E.g. cs.CG, cond-mat.dis-nn, etc.""" + is_primary = Column(Integer, nullable=False, server_default=text("'0'")) + + category_def = relationship('CategoryDef') + document = relationship('Document') + + +class User(Base): # type: ignore + """Represents an arXiv user.""" + + __tablename__ = 'tapir_users' + + user_id = Column(Integer, primary_key=True) + first_name = Column(String(50), index=True) + last_name = Column(String(50), index=True) + suffix_name = Column(String(50)) + share_first_name = Column(Integer, nullable=False, + server_default=text("'1'")) + share_last_name = Column(Integer, nullable=False, + server_default=text("'1'")) + email = Column(String(255), nullable=False, unique=True, + server_default=text("''")) + share_email = Column(Integer, nullable=False, server_default=text("'8'")) + email_bouncing = Column(Integer, nullable=False, + server_default=text("'0'")) + policy_class = Column(ForeignKey('tapir_policy_classes.class_id'), + nullable=False, index=True, + server_default=text("'0'")) + """ + +----------+---------------+ + | class_id | name | + +----------+---------------+ + | 1 | Administrator | + | 2 | Public user | + | 3 | Legacy user | + +----------+---------------+ + """ + + joined_date = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + joined_ip_num = Column(String(16), index=True) + joined_remote_host = Column(String(255), nullable=False, + server_default=text("''")) + flag_internal = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + flag_edit_users = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + flag_edit_system = Column(Integer, nullable=False, + server_default=text("'0'")) + flag_email_verified = Column(Integer, nullable=False, + server_default=text("'0'")) + flag_approved = Column(Integer, nullable=False, index=True, + server_default=text("'1'")) + flag_deleted = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + flag_banned = Column(Integer, nullable=False, index=True, + server_default=text("'0'")) + flag_wants_email = Column(Integer, nullable=False, + server_default=text("'0'")) + flag_html_email = Column(Integer, nullable=False, + server_default=text("'0'")) + tracking_cookie = Column(String(255), nullable=False, index=True, + server_default=text("''")) + flag_allow_tex_produced = Column(Integer, nullable=False, + server_default=text("'0'")) + + tapir_policy_class = relationship('PolicyClass') + + def to_user(self) -> domain.agent.User: + return domain.agent.User( + self.user_id, + self.email, + username=self.username, + forename=self.first_name, + surname=self.last_name, + suffix=self.suffix_name + ) + + +class Username(Base): # type: ignore + """ + Users' usernames (because why not have a separate table). + + +--------------+------------------+------+-----+---------+----------------+ + | Field | Type | Null | Key | Default | Extra | + +--------------+------------------+------+-----+---------+----------------+ + | nick_id | int(10) unsigned | NO | PRI | NULL | autoincrement | + | nickname | varchar(20) | NO | UNI | | | + | user_id | int(4) unsigned | NO | MUL | 0 | | + | user_seq | int(1) unsigned | NO | | 0 | | + | flag_valid | int(1) unsigned | NO | MUL | 0 | | + | role | int(10) unsigned | NO | MUL | 0 | | + | policy | int(10) unsigned | NO | MUL | 0 | | + | flag_primary | int(1) unsigned | NO | | 0 | | + +--------------+------------------+------+-----+---------+----------------+ + """ + + __tablename__ = 'tapir_nicknames' + + nick_id = Column(Integer, primary_key=True) + nickname = Column(String(20), nullable=False, unique=True, index=True) + user_id = Column(ForeignKey('tapir_users.user_id'), nullable=False, + server_default=text("'0'")) + user = relationship('User') + user_seq = Column(Integer, nullable=False, server_default=text("'0'")) + flag_valid = Column(Integer, nullable=False, server_default=text("'0'")) + role = Column(Integer, nullable=False, server_default=text("'0'")) + policy = Column(Integer, nullable=False, server_default=text("'0'")) + flag_primary = Column(Integer, nullable=False, server_default=text("'0'")) + + user = relationship('User') + + +# TODO: what is this? +class PolicyClass(Base): # type: ignore + """Defines user roles in the system.""" + + __tablename__ = 'tapir_policy_classes' + + class_id = Column(SmallInteger, primary_key=True) + name = Column(String(64), nullable=False, server_default=text("''")) + description = Column(Text, nullable=False) + password_storage = Column(Integer, nullable=False, + server_default=text("'0'")) + recovery_policy = Column(Integer, nullable=False, + server_default=text("'0'")) + permanent_login = Column(Integer, nullable=False, + server_default=text("'0'")) + + +class Tracking(Base): # type: ignore + """Record of SWORD submissions.""" + + __tablename__ = 'arXiv_tracking' + + tracking_id = Column(Integer, primary_key=True) + sword_id = Column(Integer, nullable=False, unique=True, + server_default=text("'00000000'")) + paper_id = Column(String(32), nullable=False) + submission_errors = Column(Text) + timestamp = Column(DateTime, nullable=False, + server_default=text("CURRENT_TIMESTAMP")) + + +class ArchiveCategory(Base): # type: ignore + """Maps categories to the archives in which they reside.""" + + __tablename__ = 'arXiv_archive_category' + + archive_id = Column(String(16), primary_key=True, nullable=False, + server_default=text("''")) + category_id = Column(String(32), primary_key=True, nullable=False) + + +class ArchiveDef(Base): # type: ignore + """Defines the archives in the arXiv classification taxonomy.""" + + __tablename__ = 'arXiv_archive_def' + + archive = Column(String(16), primary_key=True, server_default=text("''")) + name = Column(String(255)) + + +class ArchiveGroup(Base): # type: ignore + """Maps archives to the groups in which they reside.""" + + __tablename__ = 'arXiv_archive_group' + + archive_id = Column(String(16), primary_key=True, nullable=False, + server_default=text("''")) + group_id = Column(String(16), primary_key=True, nullable=False, + server_default=text("''")) + + +class Archive(Base): # type: ignore + """Supplemental data about archives in the classification hierarchy.""" + + __tablename__ = 'arXiv_archives' + + archive_id = Column(String(16), primary_key=True, + server_default=text("''")) + in_group = Column(ForeignKey('arXiv_groups.group_id'), nullable=False, + index=True, server_default=text("''")) + archive_name = Column(String(255), nullable=False, + server_default=text("''")) + start_date = Column(String(4), nullable=False, server_default=text("''")) + end_date = Column(String(4), nullable=False, server_default=text("''")) + subdivided = Column(Integer, nullable=False, server_default=text("'0'")) + + arXiv_group = relationship('Group') + + +class GroupDef(Base): # type: ignore + """Defines the groups in the arXiv classification taxonomy.""" + + __tablename__ = 'arXiv_group_def' + + archive_group = Column(String(16), primary_key=True, + server_default=text("''")) + name = Column(String(255)) + + +class Group(Base): # type: ignore + """Supplemental data about groups in the classification hierarchy.""" + + __tablename__ = 'arXiv_groups' + + group_id = Column(String(16), primary_key=True, server_default=text("''")) + group_name = Column(String(255), nullable=False, server_default=text("''")) + start_year = Column(String(4), nullable=False, server_default=text("''")) + + +class EndorsementDomain(Base): # type: ignore + """Endorsement configurations.""" + + __tablename__ = 'arXiv_endorsement_domains' + + endorsement_domain = Column(String(32), primary_key=True, + server_default=text("''")) + endorse_all = Column(Enum('y', 'n'), nullable=False, + server_default=text("'n'")) + mods_endorse_all = Column(Enum('y', 'n'), nullable=False, + server_default=text("'n'")) + endorse_email = Column(Enum('y', 'n'), nullable=False, + server_default=text("'y'")) + papers_to_endorse = Column(SmallInteger, nullable=False, + server_default=text("'4'")) + + +class Category(Base): # type: ignore + """Supplemental data about arXiv categories, including endorsement.""" + + __tablename__ = 'arXiv_categories' + + arXiv_endorsement_domain = relationship('EndorsementDomain') + + archive = Column( + ForeignKey('arXiv_archives.archive_id'), + primary_key=True, + nullable=False, + server_default=text("''") + ) + """E.g. cond-mat, astro-ph, cs.""" + arXiv_archive = relationship('Archive') + + subject_class = Column(String(16), primary_key=True, nullable=False, + server_default=text("''")) + """E.g. AI, spr-con, str-el, CO, EP.""" + + definitive = Column(Integer, nullable=False, server_default=text("'0'")) + active = Column(Integer, nullable=False, server_default=text("'0'")) + """Only use rows where active == 1.""" + + category_name = Column(String(255)) + endorse_all = Column( + Enum('y', 'n', 'd'), + nullable=False, + server_default=text("'d'") + ) + endorse_email = Column( + Enum('y', 'n', 'd'), + nullable=False, + server_default=text("'d'") + ) + endorsement_domain = Column( + ForeignKey('arXiv_endorsement_domains.endorsement_domain'), + index=True + ) + """E.g. astro-ph, acc-phys, chem-ph, cs.""" + + papers_to_endorse = Column(SmallInteger, nullable=False, + server_default=text("'0'")) + + +class AdminLogEntry(Base): # type: ignore + """ + + +---------------+-----------------------+------+-----+-------------------+ + | Field | Type | Null | Key | Default | + +---------------+-----------------------+------+-----+-------------------+ + | id | int(11) | NO | PRI | NULL | + | logtime | varchar(24) | YES | | NULL | + | created | timestamp | NO | | CURRENT_TIMESTAMP | + | paper_id | varchar(20) | YES | MUL | NULL | + | username | varchar(20) | YES | | NULL | + | host | varchar(64) | YES | | NULL | + | program | varchar(20) | YES | | NULL | + | command | varchar(20) | YES | MUL | NULL | + | logtext | text | YES | | NULL | + | document_id | mediumint(8) unsigned | YES | | NULL | + | submission_id | int(11) | YES | MUL | NULL | + | notify | tinyint(1) | YES | | 0 | + +---------------+-----------------------+------+-----+-------------------+ + """ + + __tablename__ = 'arXiv_admin_log' + + id = Column(Integer, primary_key=True) + logtime = Column(String(24), nullable=True) + created = Column(DateTime, default=lambda: datetime.now(UTC)) + paper_id = Column(String(20), nullable=True) + username = Column(String(20), nullable=True) + host = Column(String(64), nullable=True) + program = Column(String(20), nullable=True) + command = Column(String(20), nullable=True) + logtext = Column(Text, nullable=True) + document_id = Column(Integer, nullable=True) + submission_id = Column(Integer, nullable=True) + notify = Column(Integer, nullable=True, default=0) + + +class CategoryProposal(Base): # type: ignore + """ + Represents a proposal to change the classification of a submission. + + +---------------------+-----------------+------+-----+---------+ + | Field | Type | Null | Key | Default | + +---------------------+-----------------+------+-----+---------+ + | proposal_id | int(11) | NO | PRI | NULL | + | submission_id | int(11) | NO | PRI | NULL | + | category | varchar(32) | NO | PRI | NULL | + | is_primary | tinyint(1) | NO | PRI | 0 | + | proposal_status | int(11) | YES | | 0 | + | user_id | int(4) unsigned | NO | MUL | NULL | + | updated | datetime | YES | | NULL | + | proposal_comment_id | int(11) | YES | MUL | NULL | + | response_comment_id | int(11) | YES | MUL | NULL | + +---------------------+-----------------+------+-----+---------+ + """ + + __tablename__ = 'arXiv_submission_category_proposal' + + UNRESOLVED = 0 + ACCEPTED_AS_PRIMARY = 1 + ACCEPTED_AS_SECONDARY = 2 + REJECTED = 3 + DOMAIN_STATUS = { + UNRESOLVED: domain.proposal.Proposal.Status.PENDING, + ACCEPTED_AS_PRIMARY: domain.proposal.Proposal.Status.ACCEPTED, + ACCEPTED_AS_SECONDARY: domain.proposal.Proposal.Status.ACCEPTED, + REJECTED: domain.proposal.Proposal.Status.REJECTED + } + + proposal_id = Column(Integer, primary_key=True) + submission_id = Column(ForeignKey('arXiv_submissions.submission_id')) + submission = relationship('Submission') + category = Column(String(32)) + is_primary = Column(Integer, server_default=text("'0'")) + proposal_status = Column(Integer, nullable=True, server_default=text("'0'")) + user_id = Column(ForeignKey('tapir_users.user_id')) + user = relationship("User") + updated = Column(DateTime, default=lambda: datetime.now(UTC)) + proposal_comment_id = Column(ForeignKey('arXiv_admin_log.id'), + nullable=True) + proposal_comment = relationship("AdminLogEntry", + foreign_keys=[proposal_comment_id]) + response_comment_id = Column(ForeignKey('arXiv_admin_log.id'), + nullable=True) + response_comment = relationship("AdminLogEntry", + foreign_keys=[response_comment_id]) + + def status_from_domain(self, proposal: domain.proposal.Proposal) -> int: + if proposal.status == domain.proposal.Proposal.Status.PENDING: + return self.UNRESOLVED + elif proposal.status == domain.proposal.Proposal.Status.REJECTED: + return self.REJECTED + elif proposal.status == domain.proposal.Proposal.Status.ACCEPTED: + if proposal.proposed_event_type \ + is domain.event.SetPrimaryClassification: + return self.ACCEPTED_AS_PRIMARY + else: + return self.ACCEPTED_AS_SECONDARY + raise RuntimeError(f'Could not determine status: {proposal.status}') + + + +def _load_document(paper_id: str) -> Document: + with transaction() as session: + document: Document = session.query(Document) \ + .filter(Document.paper_id == paper_id) \ + .one() + if document is None: + raise RuntimeError('No such document') + return document + + +def _get_user_by_username(username: str) -> User: + with transaction() as session: + u: User = session.query(Username) \ + .filter(Username.nickname == username) \ + .first() \ + .user + return u diff --git a/src/arxiv/submission/services/classic/patch.py b/src/arxiv/submission/services/classic/patch.py new file mode 100644 index 0000000..9cdf06b --- /dev/null +++ b/src/arxiv/submission/services/classic/patch.py @@ -0,0 +1,122 @@ +"""Methods for updating :class:`.Submission` with state outside event scope.""" + +from typing import List, Dict, Any, Type + +from ... import domain +from ...domain.submission import UserRequest +from . import models + + +def patch_hold(submission: domain.Submission, + row: models.Submission) -> domain.Submission: + """Patch hold-related data from this database row.""" + if not row.is_new_version(): + raise ValueError('Only applies to new and replacement rows') + + if row.status == row.ON_HOLD: + created = row.get_updated() + creator = domain.agent.System(__name__) + event_id = domain.Event.get_id(created, 'AddHold', creator) + hold = domain.Hold(event_id=event_id, creator=creator, + created=created, + hold_type=domain.Hold.Type.PATCH) + submission.holds[event_id] = hold + return submission + + +def patch_jref(submission: domain.Submission, + row: models.Submission) -> domain.Submission: + """ + Patch a :class:`.domain.submission.Submission` with JREF data outside the event scope. + + Parameters + ---------- + submission : :class:`.domain.submission.Submission` + The submission object to patch. + + Returns + ------- + :class:`.domain.submission.Submission` + The same submission that was passed; now patched with JREF data + outside the scope of the event model. + + """ + submission.metadata.doi = row.doi + submission.metadata.journal_ref = row.journal_ref + submission.metadata.report_num = row.report_num + return submission + + +# This should update the reason_for_withdrawal (if applied), +# and add a WithdrawalRequest to user_requests. +def patch_withdrawal(submission: domain.Submission, row: models.Submission, + request_number: int = -1) -> domain.Submission: + req_type = domain.WithdrawalRequest + data = {'reason_for_withdrawal': row.get_withdrawal_reason()} + return _patch_request(req_type, data, submission, row, request_number) + + +def patch_cross(submission: domain.Submission, row: models.Submission, + request_number: int = -1) -> domain.Submission: + req_type = domain.CrossListClassificationRequest + clsns = [domain.Classification(dbc.category) for dbc in row.categories + if not dbc.is_primary + and dbc.category not in submission.secondary_categories] + data = {'classifications': clsns} + return _patch_request(req_type, data, submission, row, request_number) + + +def _patch_request(req_type: Type[UserRequest], data: Dict[str, Any], + submission: domain.Submission, row: models.Submission, + request_number: int = -1) -> domain.Submission: + status = req_type.WORKING + if row.is_announced(): + status = req_type.APPLIED + elif row.is_deleted(): + status = req_type.CANCELLED + elif row.is_rejected(): + status = req_type.REJECTED + elif not row.is_working(): + status = req_type.PENDING # Includes hold state. + data.update({'status': status}) + request_id = req_type.generate_request_id(submission, request_number) + + if request_number < 0: + creator = domain.User(native_id=row.submitter_id, + email=row.submitter_email) + user_request = req_type(creator=creator, created=row.get_created(), + updated=row.get_updated(), + request_id=request_id, **data) + else: + user_request = submission.user_requests[request_id] + if any([setattr_changed(user_request, field, value) + for field, value in data.items()]): + user_request.updated = row.get_updated() + submission.user_requests[request_id] = user_request + + if status == req_type.APPLIED: + submission = user_request.apply(submission) + return submission + + +def setattr_changed(obj: Any, field: str, value: Any) -> bool: + """ + Set an attribute on an object only if the value does not match provided. + + Parameters + ---------- + obj : object + field : str + The name of the attribute on ``obj`` to set. + value : object + + Returns + ------- + bool + True if the attribute was set; otherwise False. + + """ + if getattr(obj, field) != value: + setattr(obj, field, value) + return True + return False diff --git a/src/arxiv/submission/services/classic/proposal.py b/src/arxiv/submission/services/classic/proposal.py new file mode 100644 index 0000000..4f2b89d --- /dev/null +++ b/src/arxiv/submission/services/classic/proposal.py @@ -0,0 +1,64 @@ +"""Integration with classic proposals.""" + +from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound + +from . import models, util, log +from ... import domain +from ...domain.event import Event, SetPrimaryClassification, \ + AddSecondaryClassification, AddProposal +from ...domain.submission import Submission + + +def add(event: AddProposal, before: Submission, after: Submission) -> None: + """ + Add a category proposal to the database. + + The objective here is simply to create a new proposal entry in the classic + database when an :class:`domain.event.AddProposal` event is stored. + + Parameters + ---------- + event : :class:`event.Event` + The event being committed. + before : :class:`.domain.submission.Submission` + State of the submission before the event. + after : :class:`.domain.submission.Submission` + State of the submission after the event. + + """ + supported = [SetPrimaryClassification, AddSecondaryClassification] + if event.proposed_event_type not in supported: + return + + category = event.proposed_event_data['category'] + is_primary = event.proposed_event_type is SetPrimaryClassification + with util.transaction() as session: + try: + existing_proposal = session.query(models.CategoryProposal) \ + .filter(models.CategoryProposal.submission_id == after.submission_id) \ + .filter(models.CategoryProposal.category == category) \ + .one() + return # Proposal already exists. + except MultipleResultsFound: + return # Proposal already exists (in spades!). + except NoResultFound: + pass + comment = None + if event.comment: + comment = log.admin_log(event.creator.username, 'admin comment', + event.comment, + username=event.creator.username, + hostname=event.creator.hostname, + submission_id=after.submission_id) + + session.add( + models.CategoryProposal( + submission_id=after.submission_id, + category=category, + is_primary=int(is_primary), + user_id=event.creator.native_id, + updated=event.created, + proposal_status=models.CategoryProposal.UNRESOLVED, + proposal_comment=comment + ) + ) diff --git a/src/arxiv/submission/services/classic/tests/__init__.py b/src/arxiv/submission/services/classic/tests/__init__.py new file mode 100644 index 0000000..61a51b9 --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/__init__.py @@ -0,0 +1,11 @@ +""" +Integration tests for the classic database service. + +These tests assume that SQLAlchemy's MySQL backend is implemented correctly: +instead of using a live MySQL database, they use an in-memory SQLite database. +This is mostly fine (they are intended to be more-or-less swappable). The one +iffy bit is the JSON datatype, which is not available by default in the SQLite +backend. We extend the SQLite engine with a JSON type in +:mod:`arxiv.submission.services.classic.util`. End to end tests with a live +MySQL database will provide more confidence in this area. +""" diff --git a/src/arxiv/submission/services/classic/tests/test_admin_log.py b/src/arxiv/submission/services/classic/tests/test_admin_log.py new file mode 100644 index 0000000..f924d9f --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_admin_log.py @@ -0,0 +1,97 @@ +"""Tests for admin log integration.""" + +from unittest import TestCase, mock +import os +from datetime import datetime +from contextlib import contextmanager +import json +from pytz import UTC + +from flask import Flask + +from ....domain.agent import User, System +from ....domain.submission import Submission, Author +from ....domain.event import CreateSubmission, ConfirmPolicy, SetTitle +from .. import models, store_event, log, current_session + +from .util import in_memory_db + + +class TestAdminLog(TestCase): + """Test adding an admin long entry with :func:`.log.admin_log`.""" + + def test_add_admin_log_entry(self): + """Add a log entry.""" + with in_memory_db(): + log.admin_log( + "fooprogram", + "test", + "this is a test of the admin log", + username="foouser", + hostname="127.0.0.1", + submission_id=5 + ) + + session = current_session() + logs = session.query(models.AdminLogEntry).all() + self.assertEqual(len(logs), 1) + self.assertEqual(logs[0].program, "fooprogram") + self.assertEqual(logs[0].command, "test") + self.assertEqual(logs[0].logtext, + "this is a test of the admin log") + self.assertEqual(logs[0].username, "foouser") + self.assertEqual(logs[0].host, "127.0.0.1") + self.assertEqual(logs[0].submission_id, 5) + self.assertEqual(logs[0].paper_id, "submit/5") + self.assertFalse(logs[0].notify) + self.assertIsNone(logs[0].document_id) + + +class TestOnEvent(TestCase): + """Functions in :const:`.log.ON_EVENT` are called.""" + + def test_on_event(self): + """Function in :const:`.log.ON_EVENT` is called.""" + mock_handler = mock.MagicMock() + log.ON_EVENT[ConfirmPolicy] = [mock_handler] + user = User(12345, 'joe@joe.joe', username="joeuser", + endorsements=['physics.soc-ph', 'cs.DL']) + event = ConfirmPolicy(creator=user) + before = Submission(creator=user, owner=user, submission_id=42) + after = Submission(creator=user, owner=user, submission_id=42) + log.handle(event, before, after) + self.assertEqual(mock_handler.call_count, 1, + "Handler registered for ConfirmPolicy is called") + + def test_on_event_is_specific(self): + """Function in :const:`.log.ON_EVENT` are specific.""" + mock_handler = mock.MagicMock() + log.ON_EVENT[ConfirmPolicy] = [mock_handler] + user = User(12345, 'joe@joe.joe', username="joeuser", + endorsements=['physics.soc-ph', 'cs.DL']) + event = SetTitle(creator=user, title="foo title") + before = Submission(creator=user, owner=user, submission_id=42) + after = Submission(creator=user, owner=user, submission_id=42) + log.handle(event, before, after) + self.assertEqual(mock_handler.call_count, 0, + "Handler registered for ConfirmPolicy is not called") + + +class TestStoreEvent(TestCase): + """Test log integration when storing event.""" + + def test_store_event(self): + """Log handler is called when an event is stored.""" + mock_handler = mock.MagicMock() + log.ON_EVENT[CreateSubmission] = [mock_handler] + user = User(12345, 'joe@joe.joe', username="joeuser", + endorsements=['physics.soc-ph', 'cs.DL']) + event = CreateSubmission(creator=user, created=datetime.now(UTC)) + before = None + after = Submission(creator=user, owner=user, submission_id=42) + + with in_memory_db(): + store_event(event, before, after) + + self.assertEqual(mock_handler.call_count, 1, + "Handler registered for CreateSubmission is called") diff --git a/src/arxiv/submission/services/classic/tests/test_get_licenses.py b/src/arxiv/submission/services/classic/tests/test_get_licenses.py new file mode 100644 index 0000000..4814e2f --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_get_licenses.py @@ -0,0 +1,45 @@ +"""Tests for retrieving license information.""" + +from unittest import TestCase, mock + +from flask import Flask + +from ....domain.submission import License +from .. import models, get_licenses, current_session +from .util import in_memory_db + + +class TestGetLicenses(TestCase): + """Test :func:`.get_licenses`.""" + + def test_get_all_active_licenses(self): + """Return a :class:`.domain.License` for each active license.""" + # mock_util.json_factory.return_value = SQLiteJSON + + with in_memory_db(): + session = current_session() + session.add(models.License( + name="http://arxiv.org/licenses/assumed-1991-2003", + sequence=9, + label="Assumed arXiv.org perpetual, non-exclusive license to", + active=0 + )) + session.add(models.License( + name="http://creativecommons.org/licenses/publicdomain/", + sequence=4, + label="Creative Commons Public Domain Declaration", + active=1 + )) + session.commit() + licenses = get_licenses() + + self.assertEqual(len(licenses), 1, + "Only the active license should be returned.") + self.assertIsInstance(licenses[0], License, + "Should return License instances.") + self.assertEqual(licenses[0].uri, + "http://creativecommons.org/licenses/publicdomain/", + "Should use name column to populate License.uri") + self.assertEqual(licenses[0].name, + "Creative Commons Public Domain Declaration", + "Should use label column to populate License.name") diff --git a/src/arxiv/submission/services/classic/tests/test_get_submission.py b/src/arxiv/submission/services/classic/tests/test_get_submission.py new file mode 100644 index 0000000..29242e4 --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_get_submission.py @@ -0,0 +1,248 @@ +"""Tests for retrieving submissions.""" + +from unittest import TestCase, mock +from datetime import datetime +from pytz import UTC +from flask import Flask + +from ....domain.agent import User, System +from ....domain.submission import License, Submission, Author +from ....domain.event import CreateSubmission, \ + FinalizeSubmission, SetPrimaryClassification, AddSecondaryClassification, \ + SetLicense, SetPrimaryClassification, ConfirmPolicy, \ + ConfirmContactInformation, SetTitle, SetAbstract, SetDOI, \ + SetMSCClassification, SetACMClassification, SetJournalReference, \ + SetComments, SetAuthors, Announce, ConfirmAuthorship, ConfirmPolicy, \ + SetUploadPackage +from .. import init_app, create_all, drop_all, models, DBEvent, \ + get_submission, get_user_submissions_fast, current_session, get_licenses, \ + exceptions, store_event, transaction + +from .util import in_memory_db + + +class TestGetSubmission(TestCase): + """Test :func:`.classic.get_submission`.""" + + def test_get_submission_that_does_not_exist(self): + """Test that an exception is raised when submission doesn't exist.""" + with in_memory_db(): + with self.assertRaises(exceptions.NoSuchSubmission): + get_submission(1) + + def test_get_submission_with_publish(self): + """Test that publication state is reflected in submission data.""" + user = User(12345, 'joe@joe.joe', + endorsements=['physics.soc-ph', 'cs.DL']) + + events = [ + CreateSubmission(creator=user), + SetTitle(creator=user, title='Foo title'), + SetAbstract(creator=user, abstract='Indeed' * 10), + SetAuthors(creator=user, authors=[ + Author(order=0, forename='Joe', surname='Bloggs', + email='joe@blo.ggs'), + Author(order=1, forename='Jane', surname='Doe', + email='j@doe.com'), + ]), + SetLicense(creator=user, license_uri='http://foo.org/1.0/', + license_name='Foo zero 1.0'), + SetPrimaryClassification(creator=user, category='cs.DL'), + ConfirmPolicy(creator=user), + SetUploadPackage(creator=user, identifier='12345'), + ConfirmContactInformation(creator=user), + FinalizeSubmission(creator=user) + ] + + with in_memory_db(): + # User creates and finalizes submission. + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events[i] = event + before = after + submission = after + + ident = submission.submission_id + + session = current_session() + # Moderation happens, things change outside the event model. + db_submission = session.query(models.Submission).get(ident) + + # Announced! + db_submission.status = db_submission.ANNOUNCED + db_document = models.Document(paper_id='1901.00123') + db_submission.document = db_document + session.add(db_submission) + session.add(db_document) + session.commit() + + # Now get the submission. + submission_loaded, _ = get_submission(ident) + + self.assertEqual(submission.metadata.title, + submission_loaded.metadata.title, + "Event-derived metadata should be preserved.") + self.assertEqual(submission_loaded.arxiv_id, "1901.00123", + "arXiv paper ID should be set") + self.assertEqual(submission_loaded.status, Submission.ANNOUNCED, + "Submission status should reflect publish action") + + def test_get_submission_with_hold_and_reclass(self): + """Test changes made externally are reflected in submission data.""" + user = User(12345, 'joe@joe.joe', + endorsements=['physics.soc-ph', 'cs.DL']) + events = [ + CreateSubmission(creator=user), + SetTitle(creator=user, title='Foo title'), + SetAbstract(creator=user, abstract='Indeed' * 20), + SetAuthors(creator=user, authors=[ + Author(order=0, forename='Joe', surname='Bloggs', + email='joe@blo.ggs'), + Author(order=1, forename='Jane', surname='Doe', + email='j@doe.com'), + ]), + SetLicense(creator=user, license_uri='http://foo.org/1.0/', + license_name='Foo zero 1.0'), + SetPrimaryClassification(creator=user, category='cs.DL'), + ConfirmPolicy(creator=user), + SetUploadPackage(creator=user, identifier='12345'), + ConfirmContactInformation(creator=user), + FinalizeSubmission(creator=user) + ] + + with in_memory_db(): + # User creates and finalizes submission. + with transaction(): + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events[i] = event + before = after + submission = after + ident = submission.submission_id + + session = current_session() + # Moderation happens, things change outside the event model. + db_submission = session.query(models.Submission).get(ident) + + # Reclassification! + session.delete(db_submission.primary_classification) + session.add(models.SubmissionCategory( + submission_id=ident, category='cs.IR', is_primary=1 + )) + + # On hold! + db_submission.status = db_submission.ON_HOLD + session.add(db_submission) + session.commit() + + # Now get the submission. + submission_loaded, _ = get_submission(ident) + + self.assertEqual(submission.metadata.title, + submission_loaded.metadata.title, + "Event-derived metadata should be preserved.") + self.assertEqual(submission_loaded.primary_classification.category, + "cs.IR", + "Primary classification should reflect the" + " reclassification that occurred outside the purview" + " of the event model.") + self.assertEqual(submission_loaded.status, Submission.SUBMITTED, + "Submission status should still be submitted.") + self.assertTrue(submission_loaded.is_on_hold, + "Hold status should reflect hold action performed" + " outside the purview of the event model.") + + def test_get_submission_list(self): + """Test that the set of submissions for a user can be retrieved.""" + user = User(42, 'adent@example.org', + endorsements=['astro-ph.GA', 'astro-ph.EP']) + events1 = [ + # first submission + CreateSubmission(creator=user), + SetTitle(creator=user, title='Foo title'), + SetAbstract(creator=user, abstract='Indeed' * 20), + SetAuthors(creator=user, authors=[ + Author(order=0, forename='Arthur', surname='Dent', + email='adent@example.org'), + Author(order=1, forename='Ford', surname='Prefect', + email='fprefect@example.org'), + ]), + SetLicense(creator=user, license_uri='http://creativecommons.org/publicdomain/zero/1.0/', + license_name='Foo zero 1.0'), + SetPrimaryClassification(creator=user, category='astro-ph.GA'), + ConfirmPolicy(creator=user), + SetUploadPackage(creator=user, identifier='1'), + ConfirmContactInformation(creator=user), + FinalizeSubmission(creator=user) + ] + events2 = [ + # second submission + CreateSubmission(creator=user), + SetTitle(creator=user, title='Bar title'), + SetAbstract(creator=user, abstract='Indubitably' * 20), + SetAuthors(creator=user, authors=[ + Author(order=0, forename='Jane', surname='Doe', + email='jadoe@example.com'), + Author(order=1, forename='John', surname='Doe', + email='jodoe@example.com'), + ]), + SetLicense(creator=user, license_uri='http://creativecommons.org/publicdomain/zero/1.0/', + license_name='Foo zero 1.0'), + SetPrimaryClassification(creator=user, category='astro-ph.GA'), + ConfirmPolicy(creator=user), + SetUploadPackage(creator=user, identifier='1'), + ConfirmContactInformation(creator=user), + FinalizeSubmission(creator=user) + ] + + with in_memory_db(): + # User creates and finalizes submission. + with transaction(): + before = None + for i, event in enumerate(list(events1)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events1[i] = event + before = after + submission1 = after + ident1 = submission1.submission_id + + before = None + for i, event in enumerate(list(events2)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events2[i] = event + before = after + submission2 = after + ident2 = submission2.submission_id + + classic_sub = models.Submission( + type='new', + submitter_id=42) + session = current_session() + session.add(classic_sub) + + # Now get the submissions for this user. + submissions = get_user_submissions_fast(42) + submission_loaded1, _ = get_submission(ident1) + submission_loaded2, _ = get_submission(ident2) + + self.assertEqual(submission1.metadata.title, + submission_loaded1.metadata.title, + "Event-derived metadata for submission 1 should be preserved.") + self.assertEqual(submission2.metadata.title, + submission_loaded2.metadata.title, + "Event-derived metadata for submission 2 should be preserved.") + + self.assertEqual(len(submissions), + 2, + "There should be exactly two NG submissions.") + diff --git a/src/arxiv/submission/services/classic/tests/test_store_annotations.py b/src/arxiv/submission/services/classic/tests/test_store_annotations.py new file mode 100644 index 0000000..ed0dfd3 --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_store_annotations.py @@ -0,0 +1 @@ +"""Test persistence of annotations in the classic database.""" diff --git a/src/arxiv/submission/services/classic/tests/test_store_event.py b/src/arxiv/submission/services/classic/tests/test_store_event.py new file mode 100644 index 0000000..3fb655b --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_store_event.py @@ -0,0 +1,318 @@ +"""Tests for storing events.""" + +from unittest import TestCase, mock +from datetime import datetime +from pytz import UTC +from flask import Flask + +from ....domain.agent import User, System +from ....domain.submission import License, Submission, Author +from ....domain.event import CreateSubmission, \ + FinalizeSubmission, SetPrimaryClassification, AddSecondaryClassification, \ + SetLicense, ConfirmPolicy, ConfirmContactInformation, SetTitle, \ + SetAbstract, SetDOI, SetMSCClassification, SetACMClassification, \ + SetJournalReference, SetComments, SetAuthors, Announce, \ + ConfirmAuthorship, SetUploadPackage +from .. import init_app, create_all, drop_all, models, DBEvent, \ + get_submission, current_session, get_licenses, exceptions, store_event, \ + transaction + + +from .util import in_memory_db + + +class TestStoreEvent(TestCase): + """Tests for :func:`.store_event`.""" + + def setUp(self): + """Instantiate a user.""" + self.user = User(12345, 'joe@joe.joe', + endorsements=['physics.soc-ph', 'cs.DL']) + + def test_store_creation(self): + """Store a :class:`CreateSubmission`.""" + with in_memory_db(): + session = current_session() + before = None + event = CreateSubmission(creator=self.user) + event.created = datetime.now(UTC) + after = event.apply(before) + + event, after = store_event(event, before, after) + + db_sb = session.query(models.Submission).get(event.submission_id) + + # Make sure that we get the right submission ID. + self.assertIsNotNone(event.submission_id) + self.assertEqual(event.submission_id, after.submission_id) + self.assertEqual(event.submission_id, db_sb.submission_id) + + self.assertEqual(db_sb.status, models.Submission.NOT_SUBMITTED) + self.assertEqual(db_sb.type, models.Submission.NEW_SUBMISSION) + self.assertEqual(db_sb.version, 1) + + def test_store_events_with_metadata(self): + """Store events and attendant submission with metadata.""" + metadata = { + 'title': 'foo title', + 'abstract': 'very abstract' * 20, + 'comments': 'indeed', + 'msc_class': 'foo msc', + 'acm_class': 'F.2.2; I.2.7', + 'doi': '10.1000/182', + 'journal_ref': 'Nature 1991 2: 1', + 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] + } + with in_memory_db(): + + ev = CreateSubmission(creator=self.user) + ev2 = SetTitle(creator=self.user, title=metadata['title']) + ev3 = SetAbstract(creator=self.user, abstract=metadata['abstract']) + ev4 = SetComments(creator=self.user, comments=metadata['comments']) + ev5 = SetMSCClassification(creator=self.user, + msc_class=metadata['msc_class']) + ev6 = SetACMClassification(creator=self.user, + acm_class=metadata['acm_class']) + ev7 = SetJournalReference(creator=self.user, + journal_ref=metadata['journal_ref']) + ev8 = SetDOI(creator=self.user, doi=metadata['doi']) + events = [ev, ev2, ev3, ev4, ev5, ev6, ev7, ev8] + + with transaction(): + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events[i] = event + before = after + + session = current_session() + db_submission = session.query(models.Submission)\ + .get(after.submission_id) + db_events = session.query(DBEvent).all() + + for key, value in metadata.items(): + if key == 'authors': + continue + self.assertEqual(getattr(db_submission, key), value, + f"The value of {key} should be {value}") + self.assertEqual(db_submission.authors, + after.metadata.authors_display, + "The canonical author string should be used to" + " update the submission in the database.") + + self.assertEqual(len(db_events), 8, + "Eight events should be stored") + for db_event in db_events: + self.assertEqual(db_event.submission_id, after.submission_id, + "The submission id should be set") + + def test_store_events_with_finalized_submission(self): + """Store events and a finalized submission.""" + metadata = { + 'title': 'foo title', + 'abstract': 'very abstract' * 20, + 'comments': 'indeed', + 'msc_class': 'foo msc', + 'acm_class': 'F.2.2; I.2.7', + 'doi': '10.1000/182', + 'journal_ref': 'Nature 1991 2: 1', + 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] + } + with in_memory_db(): + + events = [ + CreateSubmission(creator=self.user), + ConfirmContactInformation(creator=self.user), + ConfirmAuthorship(creator=self.user, submitter_is_author=True), + ConfirmContactInformation(creator=self.user), + ConfirmPolicy(creator=self.user), + SetTitle(creator=self.user, title=metadata['title']), + SetAuthors(creator=self.user, authors=[ + Author(order=0, forename='Joe', surname='Bloggs', + email='joe@blo.ggs'), + Author(order=1, forename='Jane', surname='Doe', + email='j@doe.com'), + ]), + SetAbstract(creator=self.user, abstract=metadata['abstract']), + SetComments(creator=self.user, comments=metadata['comments']), + SetMSCClassification(creator=self.user, + msc_class=metadata['msc_class']), + SetACMClassification(creator=self.user, + acm_class=metadata['acm_class']), + SetJournalReference(creator=self.user, + journal_ref=metadata['journal_ref']), + SetDOI(creator=self.user, doi=metadata['doi']), + SetLicense(creator=self.user, + license_uri='http://foo.org/1.0/', + license_name='Foo zero 1.0'), + SetUploadPackage(creator=self.user, identifier='12345'), + SetPrimaryClassification(creator=self.user, + category='physics.soc-ph'), + FinalizeSubmission(creator=self.user) + ] + + with transaction(): + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events[i] = event + before = after + + session = current_session() + db_submission = session.query(models.Submission) \ + .get(after.submission_id) + db_events = session.query(DBEvent).all() + + self.assertEqual(db_submission.submission_id, after.submission_id, + "The submission should be updated with the PK id") + self.assertEqual(db_submission.status, models.Submission.SUBMITTED, + "Submission should be in submitted state.") + self.assertEqual(len(db_events), len(events), + "%i events should be stored" % len(events)) + for db_event in db_events: + self.assertEqual(db_event.submission_id, after.submission_id, + "The submission id should be set") + + def test_store_doi_jref_with_publication(self): + """:class:`SetDOI` or :class:`SetJournalReference` after pub.""" + metadata = { + 'title': 'foo title', + 'abstract': 'very abstract' * 20, + 'comments': 'indeed', + 'msc_class': 'foo msc', + 'acm_class': 'F.2.2; I.2.7', + 'doi': '10.1000/182', + 'journal_ref': 'Nature 1991 2: 1', + 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] + } + + with in_memory_db(): + events = [ + CreateSubmission(creator=self.user), + ConfirmContactInformation(creator=self.user), + ConfirmAuthorship(creator=self.user, submitter_is_author=True), + ConfirmContactInformation(creator=self.user), + ConfirmPolicy(creator=self.user), + SetTitle(creator=self.user, title=metadata['title']), + SetAuthors(creator=self.user, authors=[ + Author(order=0, forename='Joe', surname='Bloggs', + email='joe@blo.ggs'), + Author(order=1, forename='Jane', surname='Doe', + email='j@doe.com'), + ]), + SetAbstract(creator=self.user, abstract=metadata['abstract']), + SetComments(creator=self.user, comments=metadata['comments']), + SetMSCClassification(creator=self.user, + msc_class=metadata['msc_class']), + SetACMClassification(creator=self.user, + acm_class=metadata['acm_class']), + SetJournalReference(creator=self.user, + journal_ref=metadata['journal_ref']), + SetDOI(creator=self.user, doi=metadata['doi']), + SetLicense(creator=self.user, + license_uri='http://foo.org/1.0/', + license_name='Foo zero 1.0'), + SetUploadPackage(creator=self.user, identifier='12345'), + SetPrimaryClassification(creator=self.user, + category='physics.soc-ph'), + FinalizeSubmission(creator=self.user) + ] + + with transaction(): + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event = store_event(event, before, after) + events[i] = event + before = after + + session = current_session() + # Announced! + paper_id = '1901.00123' + db_submission = session.query(models.Submission) \ + .get(after.submission_id) + db_submission.status = db_submission.ANNOUNCED + db_document = models.Document(paper_id=paper_id) + db_submission.doc_paper_id = paper_id + db_submission.document = db_document + session.add(db_submission) + session.add(db_document) + session.commit() + + # This would normally happen during a load. + pub = Announce(creator=System(__name__), arxiv_id=paper_id, + committed=True) + before = pub.apply(before) + + # Now set DOI + journal ref + doi = '10.1000/182' + journal_ref = 'foo journal 1994' + e3 = SetDOI(creator=self.user, doi=doi, + submission_id=after.submission_id, + created=datetime.now(UTC)) + after = e3.apply(before) + with transaction(): + store_event(e3, before, after) + + e4 = SetJournalReference(creator=self.user, + journal_ref=journal_ref, + submission_id=after.submission_id, + created=datetime.now(UTC)) + before = after + after = e4.apply(before) + with transaction(): + store_event(e4, before, after) + + session = current_session() + # What happened. + db_submission = session.query(models.Submission) \ + .filter(models.Submission.doc_paper_id == paper_id) \ + .order_by(models.Submission.submission_id.desc()) + self.assertEqual(db_submission.count(), 2, + "Creates a second row for the JREF") + db_jref = db_submission.first() + self.assertTrue(db_jref.is_jref()) + self.assertEqual(db_jref.doi, doi) + self.assertEqual(db_jref.journal_ref, journal_ref) + + def test_store_events_with_classification(self): + """Store events including classification.""" + ev = CreateSubmission(creator=self.user) + ev2 = SetPrimaryClassification(creator=self.user, + category='physics.soc-ph') + ev3 = AddSecondaryClassification(creator=self.user, + category='physics.acc-ph') + events = [ev, ev2, ev3] + + with in_memory_db(): + with transaction(): + before = None + for i, event in enumerate(list(events)): + event.created = datetime.now(UTC) + after = event.apply(before) + event, after = store_event(event, before, after) + events[i] = event + before = after + + session = current_session() + db_submission = session.query(models.Submission)\ + .get(after.submission_id) + db_events = session.query(DBEvent).all() + + self.assertEqual(db_submission.submission_id, after.submission_id, + "The submission should be updated with the PK id") + self.assertEqual(len(db_events), 3, + "Three events should be stored") + for db_event in db_events: + self.assertEqual(db_event.submission_id, after.submission_id, + "The submission id should be set") + self.assertEqual(len(db_submission.categories), 2, + "Two category relations should be set") + self.assertEqual(db_submission.primary_classification.category, + after.primary_classification.category, + "Primary classification should be set.") diff --git a/src/arxiv/submission/services/classic/tests/test_store_proposals.py b/src/arxiv/submission/services/classic/tests/test_store_proposals.py new file mode 100644 index 0000000..0d10bf4 --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/test_store_proposals.py @@ -0,0 +1,139 @@ +"""Test persistence of proposals in the classic database.""" + +from unittest import TestCase, mock +from datetime import datetime +from pytz import UTC +from ....domain.event import CreateSubmission, SetPrimaryClassification, \ + AddSecondaryClassification, SetTitle, AddProposal +from ....domain.agent import User +from ....domain.annotation import Comment +from ....domain.submission import Submission +from ....domain.proposal import Proposal +from .. import store_event, models, get_events, current_session, transaction + +from .util import in_memory_db + +from arxiv import taxonomy + + +class TestSaveProposal(TestCase): + """An :class:`AddProposal` event is stored.""" + + def setUp(self): + """Instantiate a user.""" + self.user = User(12345, 'joe@joe.joe', + endorsements=['physics.soc-ph', 'cs.DL']) + + def test_save_reclassification_proposal(self): + """A submission has a new reclassification proposal.""" + with in_memory_db(): + create = CreateSubmission(creator=self.user, + created=datetime.now(UTC)) + before, after = None, create.apply(None) + create, before = store_event(create, before, after) + + event = AddProposal( + creator=self.user, + proposed_event_type=SetPrimaryClassification, + proposed_event_data={ + 'category': taxonomy.Category('cs.DL'), + }, + comment='foo', + created=datetime.now(UTC) + ) + after = event.apply(before) + with transaction(): + event, after = store_event(event, before, after) + + session = current_session() + db_sb = session.query(models.Submission).get(event.submission_id) + + # Make sure that we get the right submission ID. + self.assertIsNotNone(event.submission_id) + self.assertEqual(event.submission_id, after.submission_id) + self.assertEqual(event.submission_id, db_sb.submission_id) + + db_props = session.query(models.CategoryProposal).all() + self.assertEqual(len(db_props), 1) + self.assertEqual(db_props[0].submission_id, after.submission_id) + self.assertEqual(db_props[0].category, 'cs.DL') + self.assertEqual(db_props[0].is_primary, 1) + self.assertEqual(db_props[0].updated.replace(tzinfo=UTC), + event.created) + self.assertEqual(db_props[0].proposal_status, + models.CategoryProposal.UNRESOLVED) + + self.assertEqual(db_props[0].proposal_comment.logtext, + event.comment) + + def test_save_secondary_proposal(self): + """A submission has a new cross-list proposal.""" + with in_memory_db(): + create = CreateSubmission(creator=self.user, + created=datetime.now(UTC)) + before, after = None, create.apply(None) + create, before = store_event(create, before, after) + + event = AddProposal( + creator=self.user, + created=datetime.now(UTC), + proposed_event_type=AddSecondaryClassification, + proposed_event_data={ + 'category': taxonomy.Category('cs.DL'), + }, + comment='foo' + ) + after = event.apply(before) + with transaction(): + event, after = store_event(event, before, after) + + session = current_session() + db_sb = session.query(models.Submission).get(event.submission_id) + + # Make sure that we get the right submission ID. + self.assertIsNotNone(event.submission_id) + self.assertEqual(event.submission_id, after.submission_id) + self.assertEqual(event.submission_id, db_sb.submission_id) + + db_props = session.query(models.CategoryProposal).all() + self.assertEqual(len(db_props), 1) + self.assertEqual(db_props[0].submission_id, after.submission_id) + self.assertEqual(db_props[0].category, 'cs.DL') + self.assertEqual(db_props[0].is_primary, 0) + self.assertEqual(db_props[0].updated.replace(tzinfo=UTC), + event.created) + self.assertEqual(db_props[0].proposal_status, + models.CategoryProposal.UNRESOLVED) + + self.assertEqual(db_props[0].proposal_comment.logtext, + event.comment) + + def test_save_title_proposal(self): + """A submission has a new SetTitle proposal.""" + with in_memory_db(): + create = CreateSubmission(creator=self.user, + created=datetime.now(UTC)) + before, after = None, create.apply(None) + create, before = store_event(create, before, after) + + event = AddProposal( + creator=self.user, + created=datetime.now(UTC), + proposed_event_type=SetTitle, + proposed_event_data={'title': 'the foo title'}, + comment='foo' + ) + after = event.apply(before) + with transaction(): + event, after = store_event(event, before, after) + + session = current_session() + db_sb = session.query(models.Submission).get(event.submission_id) + + # Make sure that we get the right submission ID. + self.assertIsNotNone(event.submission_id) + self.assertEqual(event.submission_id, after.submission_id) + self.assertEqual(event.submission_id, db_sb.submission_id) + + db_props = session.query(models.CategoryProposal).all() + self.assertEqual(len(db_props), 0) diff --git a/src/arxiv/submission/services/classic/tests/util.py b/src/arxiv/submission/services/classic/tests/util.py new file mode 100644 index 0000000..06b799f --- /dev/null +++ b/src/arxiv/submission/services/classic/tests/util.py @@ -0,0 +1,24 @@ +from contextlib import contextmanager + +from flask import Flask + +from .. import init_app, create_all, drop_all, models, DBEvent, \ + get_submission, current_session, get_licenses, exceptions, store_event + + +@contextmanager +def in_memory_db(app=None): + """Provide an in-memory sqlite database for testing purposes.""" + if app is None: + app = Flask('foo') + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + init_app(app) + with app.app_context(): + create_all() + try: + yield + except Exception: + raise + finally: + drop_all() diff --git a/src/arxiv/submission/services/classic/util.py b/src/arxiv/submission/services/classic/util.py new file mode 100644 index 0000000..5828fb4 --- /dev/null +++ b/src/arxiv/submission/services/classic/util.py @@ -0,0 +1,115 @@ +"""Utility classes and functions for :mod:`.services.classic`.""" + +import json +from contextlib import contextmanager +from typing import Optional, Generator, Union, Any + +import sqlalchemy.types as types +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm.session import Session +from sqlalchemy.orm import sessionmaker + +from arxiv.base import logging +from arxiv.base.globals import get_application_config, get_application_global +from .exceptions import ClassicBaseException, TransactionFailed +from ... import serializer +from ...exceptions import InvalidEvent + +logger = logging.getLogger(__name__) + +class ClassicSQLAlchemy(SQLAlchemy): + """SQLAlchemy integration for the classic database.""" + + def init_app(self, app: Flask) -> None: + """Set default configuration.""" + logger.debug('SQLALCHEMY_DATABASE_URI %s', + app.config.get('SQLALCHEMY_DATABASE_URI', 'Not Set')) + logger.debug('CLASSIC_DATABASE_URI %s', + app.config.get('CLASSIC_DATABASE_URI', 'Not Set')) + app.config.setdefault( + 'SQLALCHEMY_DATABASE_URI', + app.config.get('CLASSIC_DATABASE_URI', 'sqlite://') + ) + app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) + # Debugging + app.config.setdefault('SQLALCHEMY_POOL_SIZE', 1) + + super(ClassicSQLAlchemy, self).init_app(app) + + def apply_pool_defaults(self, app: Flask, options: Any) -> None: + """Set options for create_engine().""" + super(ClassicSQLAlchemy, self).apply_pool_defaults(app, options) + if app.config['SQLALCHEMY_DATABASE_URI'].startswith('mysql'): + options['json_serializer'] = serializer.dumps + options['json_deserializer'] = serializer.loads + + +db: SQLAlchemy = ClassicSQLAlchemy() + + +#logger = logging.getLogger(__name__) + + +class SQLiteJSON(types.TypeDecorator): + """A SQLite-friendly JSON data type.""" + + impl = types.TEXT + + def process_bind_param(self, value: Optional[dict], dialect: str) \ + -> Optional[str]: + """Serialize a dict to JSON.""" + if value is not None: + obj: Optional[str] = serializer.dumps(value) + else: + obj = value + return obj + + def process_result_value(self, value: str, dialect: str) \ + -> Optional[Union[str, dict]]: + """Deserialize JSON content to a dict.""" + if value is not None: + value = serializer.loads(value) + return value + + +# SQLite does not support JSON, so we extend JSON to use our custom data type +# as a variant for the 'sqlite' dialect. +FriendlyJSON = types.JSON().with_variant(SQLiteJSON, 'sqlite') + + +def current_engine() -> Engine: + """Get/create :class:`.Engine` for this context.""" + return db.engine + + +def current_session() -> Session: + """Get/create :class:`.Session` for this context.""" + return db.session() + + +@contextmanager +def transaction() -> Generator: + """Context manager for database transaction.""" + session = current_session() + logger.debug('transaction with session %s', id(session)) + try: + yield session + # Only commit if there are un-flushed changes. The caller may commit + # explicitly, e.g. to do exception handling. + if session.dirty or session.deleted or session.new: + session.commit() + logger.debug('committed!') + except ClassicBaseException as e: + logger.debug('Command failed, rolling back: %s', str(e)) + session.rollback() + raise # Propagate exceptions raised from this module. + except InvalidEvent: + session.rollback() + raise + except Exception as e: + logger.debug('Command failed, rolling back: %s', str(e)) + session.rollback() + raise TransactionFailed('Failed to execute transaction') from e diff --git a/src/arxiv/submission/services/classifier/__init__.py b/src/arxiv/submission/services/classifier/__init__.py new file mode 100644 index 0000000..626a146 --- /dev/null +++ b/src/arxiv/submission/services/classifier/__init__.py @@ -0,0 +1,16 @@ +""" +Integration with the classic classifier service. + +The classifier analyzes the text of the specified paper and returns +a list of suggested categories based on similarity comparisons performed +between the text of the paper and statistics for each category. + +Typically used to evaluate article classification prior to review by +moderators. + +Unlike the original arXiv::Classifier module, this module contains no real +business-logic: the objective is simply to provide a user-friendly calling +API. +""" + +from .classifier import Classifier diff --git a/src/arxiv/submission/services/classifier/classifier.py b/src/arxiv/submission/services/classifier/classifier.py new file mode 100644 index 0000000..1d09d18 --- /dev/null +++ b/src/arxiv/submission/services/classifier/classifier.py @@ -0,0 +1,108 @@ +"""Classifier service integration.""" + +from typing import Tuple, List, Any, Union, NamedTuple, Optional +from math import exp, log +from functools import wraps + +import logging +from arxiv.taxonomy import Category +from arxiv.integration.api import status, service + +logger = logging.getLogger(__name__) + + +class Flag(NamedTuple): + """General-purpose QA flag.""" + + key: str + value: Union[int, str, dict] + + +class Suggestion(NamedTuple): + """A category suggested by the classifier.""" + + category: Category + probability: float + + +class Counts(NamedTuple): + """Various counts of paper content.""" + + chars: int + pages: int + stops: int + words: int + + +class Classifier(service.HTTPIntegration): + """Represents an interface to the classifier service.""" + + VERSION = '0.0' + SERVICE = 'classic' + + ClassifierResponse = Tuple[List[Suggestion], List[Flag], Optional[Counts]] + + class Meta: + """Configuration for :class:`Classifier`.""" + + service_name = "classifier" + + def __init__(self, endpoint: str, verify: bool = True, **params: Any): + super(Classifier, self).__init__(endpoint, verify=verify, **params) + + def is_available(self, **kwargs: Any) -> bool: + """Check our connection to the classifier service.""" + timeout: float = kwargs.get('timeout', 0.2) + try: + self.classify(b'ruok?', timeout=timeout) + except Exception as e: + logger.error('Encountered error calling classifier: %s', e) + return False + return True + + @classmethod + def probability(cls, logodds: float) -> float: + """Convert log odds to a probability.""" + return exp(logodds)/(1 + exp(logodds)) + + def _counts(self, data: dict) -> Optional[Counts]: + """Parse counts from the response data.""" + counts: Optional[Counts] = None + if 'counts' in data: + counts = Counts(**data['counts']) + return counts + + def _flags(self, data: dict) -> List[Flag]: + """Parse flags from the response data.""" + return [ + Flag(key, value) for key, value in data.get('flags', {}).items() + ] + + def _suggestions(self, data: dict) -> List[Suggestion]: + """Parse classification suggestions from the response data.""" + return [Suggestion(category=Category(datum['category']), + probability=self.probability(datum['logodds'])) + for datum in data['classifier']] + + def classify(self, content: bytes, timeout: float = 1.) \ + -> ClassifierResponse: + """ + Make a classification request to the classifier service. + + Parameters + ---------- + content : bytes + Raw text content from an e-print. + + Returns + ------- + list + A list of classifications. + list + A list of QA flags. + :class:`Counts` or None + Feature counts, if provided. + + """ + data, _, _ = self.json('post', '', data=content, timeout=timeout) + return self._suggestions(data), self._flags(data), self._counts(data) diff --git a/src/arxiv/submission/services/classifier/tests/__init__.py b/src/arxiv/submission/services/classifier/tests/__init__.py new file mode 100644 index 0000000..25c6518 --- /dev/null +++ b/src/arxiv/submission/services/classifier/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for classic classifier service integration.""" diff --git a/src/arxiv/submission/services/classifier/tests/data/linenos.json b/src/arxiv/submission/services/classifier/tests/data/linenos.json new file mode 100644 index 0000000..7013c5d --- /dev/null +++ b/src/arxiv/submission/services/classifier/tests/data/linenos.json @@ -0,0 +1 @@ +{"classifier": [{"category": "astro-ph.SR", "logodds": 1.21, "topwords": [{"taurus": 38}, {"tau": 45}, {"single stars": 30}, {"binaries": 34}, {"alma": 37}]}, {"category": "astro-ph.GA", "logodds": 0.84, "topwords": [{"alma": 37}, {"stellar mass": 24}, {"taurus": 38}, {"disk mass": 33}, {"stars": 25}]}, {"category": "astro-ph.EP", "logodds": 0.8, "topwords": [{"disk mass": 33}, {"single stars": 30}, {"alma": 37}, {"binaries": 34}, {"taurus": 38}]}, {"category": "astro-ph.HE", "logodds": 0.29}, {"category": "astro-ph.IM", "logodds": 0.27}], "counts": {"chars": 125436, "pages": 30, "stops": 3774, "words": 34211}, "flags": {"%stop": 0.11, "linenos": 5}} diff --git a/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json b/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json new file mode 100644 index 0000000..7b98aa7 --- /dev/null +++ b/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json @@ -0,0 +1,21 @@ +{ + "classifier":[ + + ], + "counts":{ + "chars":50475, + "pages":8, + "stops":9, + "words":4799 + }, + "flags":{ + "%stop":0.0, + "charset":{ + "cyrillic":2458 + }, + "language":{ + "ru":732 + }, + "stops":9 + } +} diff --git a/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json b/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json new file mode 100644 index 0000000..8d7c6b9 --- /dev/null +++ b/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json @@ -0,0 +1,74 @@ +{ + "classifier": [ + { + "category": "physics.comp-ph", + "logodds": -0.11, + "topwords": [ + { + "processors": 13 + }, + { + "fft": 13 + }, + { + "decyk": 4 + }, + { + "fast fourier transform": 7 + }, + { + "parallel": 10 + } + ] + }, + { + "category": "cs.MS", + "logodds": -0.14, + "topwords": [ + { + "fft": 13 + }, + { + "processors": 13 + }, + { + "fast fourier transform": 7 + }, + { + "parallel": 10 + }, + { + "processor": 7 + } + ] + }, + { + "category": "math.NA", + "logodds": -0.16, + "topwords": [ + { + "fft": 13 + }, + { + "fast fourier transform": 7 + }, + { + "algorithm": 6 + }, + { + "ux": 4 + }, + { + "multiplications": 5 + } + ] + } + ], + "counts": { + "chars": 15107, + "pages": 12, + "stops": 804, + "words": 2860 + }, + "flags": {} +} diff --git a/src/arxiv/submission/services/classifier/tests/tests.py b/src/arxiv/submission/services/classifier/tests/tests.py new file mode 100644 index 0000000..9e66950 --- /dev/null +++ b/src/arxiv/submission/services/classifier/tests/tests.py @@ -0,0 +1,228 @@ +"""Tests for classic classifier service integration.""" + +import os +import json +from unittest import TestCase, mock + +from flask import Flask + +from arxiv.integration.api import status, exceptions + +from .. import classifier + +DATA_PATH = os.path.join(os.path.split(os.path.abspath(__file__))[0], "data") +SAMPLE_PATH = os.path.join(DATA_PATH, "sampleResponse.json") +LINENOS_PATH = os.path.join(DATA_PATH, "linenos.json") +SAMPLE_FAILED_PATH = os.path.join(DATA_PATH, 'sampleFailedCyrillic.json') + + +class TestClassifier(TestCase): + """Tests for :class:`classifier.Classifier`.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'CLASSIFIER_ENDPOINT': 'http://foohost:1234', + 'CLASSIFIER_VERIFY': False + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_with_service_unavailable(self, mock_Session): + """The classifier service is unavailable.""" + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.SERVICE_UNAVAILABLE + ) + ) + ) + with self.app.app_context(): + cl = classifier.Classifier.current_session() + with self.assertRaises(exceptions.RequestFailed): + cl.classify(b'somecontent') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_cannot_classify(self, mock_Session): + """The classifier returns without classification suggestions.""" + with open(SAMPLE_FAILED_PATH) as f: + data = json.load(f) + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value=data) + ) + ) + ) + with self.app.app_context(): + cl = classifier.Classifier.current_session() + suggestions, flags, counts = cl.classify(b'foo') + + self.assertEqual(len(suggestions), 0, "There are no suggestions") + self.assertEqual(len(flags), 4, "There are four flags") + self.assertEqual(counts.chars, 50475) + self.assertEqual(counts.pages, 8) + self.assertEqual(counts.stops, 9) + self.assertEqual(counts.words, 4799) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_returns_suggestions(self, mock_Session): + """The classifier returns classification suggestions.""" + with open(SAMPLE_PATH) as f: + data = json.load(f) + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value=data) + ) + ) + ) + expected = { + 'physics.comp-ph': 0.47, + 'cs.MS': 0.47, + 'math.NA': 0.46 + } + with self.app.app_context(): + cl = classifier.Classifier.current_session() + suggestions, flags, counts = cl.classify(b'foo') + + self.assertEqual(len(suggestions), 3, "There are three suggestions") + for suggestion in suggestions: + self.assertEqual(round(suggestion.probability, 2), + expected[suggestion.category]) + self.assertEqual(len(flags), 0, "There are no flags") + self.assertEqual(counts.chars, 15107) + self.assertEqual(counts.pages, 12) + self.assertEqual(counts.stops, 804) + self.assertEqual(counts.words, 2860) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_withlinenos(self, mock_Session): + """The classifier returns classification suggestions.""" + with open(LINENOS_PATH) as f: + data = json.load(f) + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value=data) + ) + ) + ) + expected = { + 'astro-ph.SR': 0.77, + 'astro-ph.GA': 0.7, + 'astro-ph.EP': 0.69, + 'astro-ph.HE': 0.57, + 'astro-ph.IM': 0.57 + + } + + with self.app.app_context(): + cl = classifier.Classifier.current_session() + suggestions, flags, counts = cl.classify(b'foo') + + self.assertEqual(len(suggestions), 5, "There are five suggestions") + for suggestion in suggestions: + self.assertEqual( + round(suggestion.probability, 2), + expected[suggestion.category], + "Expected probability of %s for %s" % + (expected[suggestion.category], suggestion.category) + ) + self.assertEqual(len(flags), 2, "There are two flags") + self.assertIn("%stop", [flag.key for flag in flags]) + self.assertIn("linenos", [flag.key for flag in flags]) + self.assertEqual(counts.chars, 125436) + self.assertEqual(counts.pages, 30) + self.assertEqual(counts.stops, 3774) + self.assertEqual(counts.words, 34211) + + +class TestClassifierModule(TestCase): + """Tests for :mod:`classifier`.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'CLASSIFIER_ENDPOINT': 'http://foohost:1234', + 'CLASSIFIER_VERIFY': False + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_unavailable(self, mock_Session): + """The classifier service is unavailable.""" + mock_post = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.SERVICE_UNAVAILABLE + ) + ) + mock_Session.return_value = mock.MagicMock(post=mock_post) + with self.app.app_context(): + cl = classifier.Classifier.current_session() + with self.assertRaises(exceptions.RequestFailed): + cl.classify(b'somecontent') + endpoint = f'http://foohost:1234/' + self.assertEqual(mock_post.call_args[0][0], endpoint) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_cannot_classify(self, mock_Session): + """The classifier returns without classification suggestions.""" + with open(SAMPLE_FAILED_PATH) as f: + data = json.load(f) + mock_post = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value=data) + ) + ) + mock_Session.return_value = mock.MagicMock(post=mock_post) + with self.app.app_context(): + cl = classifier.Classifier.current_session() + suggestions, flags, counts = cl.classify(b'foo') + + self.assertEqual(len(suggestions), 0, "There are no suggestions") + self.assertEqual(len(flags), 4, "There are four flags") + self.assertEqual(counts.chars, 50475) + self.assertEqual(counts.pages, 8) + self.assertEqual(counts.stops, 9) + self.assertEqual(counts.words, 4799) + endpoint = f'http://foohost:1234/' + self.assertEqual(mock_post.call_args[0][0], endpoint) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_classifier_returns_suggestions(self, mock_Session): + """The classifier returns classification suggestions.""" + with open(SAMPLE_PATH) as f: + data = json.load(f) + mock_post = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value=data) + ) + ) + mock_Session.return_value = mock.MagicMock(post=mock_post) + expected = { + 'physics.comp-ph': 0.47, + 'cs.MS': 0.47, + 'math.NA': 0.46 + } + + with self.app.app_context(): + cl = classifier.Classifier.current_session() + suggestions, flags, counts = cl.classify(b'foo') + + self.assertEqual(len(suggestions), 3, "There are three suggestions") + for suggestion in suggestions: + self.assertEqual(round(suggestion.probability, 2), + expected[suggestion.category]) + self.assertEqual(len(flags), 0, "There are no flags") + self.assertEqual(counts.chars, 15107) + self.assertEqual(counts.pages, 12) + self.assertEqual(counts.stops, 804) + self.assertEqual(counts.words, 2860) + endpoint = f'http://foohost:1234/' + self.assertEqual(mock_post.call_args[0][0], endpoint) diff --git a/src/arxiv/submission/services/compiler/__init__.py b/src/arxiv/submission/services/compiler/__init__.py new file mode 100644 index 0000000..1c8e0b2 --- /dev/null +++ b/src/arxiv/submission/services/compiler/__init__.py @@ -0,0 +1,3 @@ +"""Integration with the compiler service API.""" + +from .compiler import Compiler, get_task_id, split_task_id, CompilationFailed diff --git a/src/arxiv/submission/services/compiler/compiler.py b/src/arxiv/submission/services/compiler/compiler.py new file mode 100644 index 0000000..ece9e1b --- /dev/null +++ b/src/arxiv/submission/services/compiler/compiler.py @@ -0,0 +1,249 @@ +""" +Integration with the compiler service API. + +The compiler is responsible for building PDF, DVI, and other goodies from +LaTeX sources. In the submission UI, we specifically want to build a PDF so +that the user can preview their submission. Additionally, we want to show the +submitter the TeX log so that they can identify any potential problems with +their sources. +""" +import io +import json +import re +from collections import defaultdict +from enum import Enum +from functools import wraps +from typing import Tuple, Optional, List, Union, NamedTuple, Mapping, Any +from urllib.parse import urlparse, urlunparse, urlencode + +import dateutil.parser +import requests +from werkzeug.datastructures import FileStorage + +from arxiv.base import logging +from arxiv.integration.api import status, service + +from ...domain.compilation import Compilation, CompilationProduct, \ + CompilationLog + + +logger = logging.getLogger(__name__) + +PDF = Compilation.Format.PDF + + +class CompilationFailed(RuntimeError): + """The compilation service failed to compile the source package.""" + + +class Compiler(service.HTTPIntegration): + """Encapsulates a connection with the compiler service.""" + + SERVICE = 'compiler' + + VERSION = "30c84dd5b5381e2f2f69ed58298bd87c10bad5c8" + """Verison of the compiler service with which we are integrating.""" + + NAME = "arxiv-compiler" + """Name of the compiler service with which we are integrating.""" + + class Meta: + """Configuration for :class:`Classifier`.""" + + service_name = "compiler" + + def is_available(self, **kwargs: Any) -> bool: + """Check our connection to the compiler service.""" + timeout: float = kwargs.get('timeout', 0.2) + try: + self.get_service_status(timeout=timeout) + except Exception as e: + logger.error('Encountered error calling compiler: %s', e) + return False + return True + + def _parse_status_response(self, data: dict, headers: dict) -> Compilation: + return Compilation( + source_id=data['source_id'], + checksum=data['checksum'], + output_format=Compilation.Format(data['output_format']), + status=Compilation.Status(data['status']), + reason=Compilation.Reason(data.get('reason', None)), + description=data.get('description', None), + size_bytes=data.get('size_bytes', 0), + product_checksum=headers.get('ETag') + ) + + def _parse_loc(self, headers: Mapping) -> str: + return str(urlparse(headers['Location']).path) + + def get_service_status(self, timeout: float = 0.2) -> dict: + """Get the status of the compiler service.""" + data: dict = self.json('get', 'status', timeout=timeout)[0] + return data + + def compile(self, source_id: str, checksum: str, token: str, + stamp_label: str, stamp_link: str, + compiler: Optional[Compilation.SupportedCompiler] = None, + output_format: Compilation.Format = PDF, + force: bool = False) -> Compilation: + """ + Request compilation for an upload workspace. + + Unless ``force`` is ``True``, the compiler service will only attempt + to compile a source ID + checksum + format combo once. If there is + already a compilation underway or complete for the parameters in this + request, the service will redirect to the corresponding status URI. + Hence the data returned by this function may be from the response to + the initial POST request, or from the status endpoint after being + redirected. + + Parameters + ---------- + source_id : int + Unique identifier for the upload workspace. + checksum : str + State up of the upload workspace. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + stamp_label : str + Label to use in PS/PDF stamp/watermark. Form is + 'Identifier [Category Date]' + Category and Date are optional. By default Date will be added + by compiler. + stamp_link : str + Link (URI) to use in PS/PDF stamp/watermark. + compiler : :class:`.Compiler` or None + Name of the preferred compiler. + output_format : :class:`.Format` + Defaults to :attr:`.Format.PDF`. + force : bool + If True, compilation will be forced even if it has been attempted + with these parameters previously. Default is ``False``. + + Returns + ------- + :class:`Compilation` + The current state of the compilation. + + """ + logger.debug("Requesting compilation for %s @ %s: %s", + source_id, checksum, output_format) + payload = {'source_id': source_id, 'checksum': checksum, + 'stamp_label': stamp_label, 'stamp_link': stamp_link, + 'format': output_format.value, 'force': force} + endpoint = '/' + expected_codes = [status.OK, status.ACCEPTED, + status.SEE_OTHER, status.FOUND] + data, _, headers = self.json('post', endpoint, token, json=payload, + expected_code=expected_codes) + return self._parse_status_response(data, headers) + + def get_status(self, source_id: str, checksum: str, token: str, + output_format: Compilation.Format = PDF) -> Compilation: + """ + Get the status of a compilation. + + Parameters + ---------- + source_id : int + Unique identifier for the upload workspace. + checksum : str + State up of the upload workspace. + output_format : :class:`.Format` + Defaults to :attr:`.Format.PDF`. + + Returns + ------- + :class:`Compilation` + The current state of the compilation. + + """ + endpoint = f'/{source_id}/{checksum}/{output_format.value}' + data, _, headers = self.json('get', endpoint, token) + return self._parse_status_response(data, headers) + + def compilation_is_complete(self, source_id: str, checksum: str, + token: str, + output_format: Compilation.Format) -> bool: + """Check whether compilation has completed successfully.""" + stat = self.get_status(source_id, checksum, token, output_format) + if stat.status is Compilation.Status.SUCCEEDED: + return True + elif stat.status is Compilation.Status.FAILED: + raise CompilationFailed('Compilation failed') + return False + + def get_product(self, source_id: str, checksum: str, token: str, + output_format: Compilation.Format = PDF) \ + -> CompilationProduct: + """ + Get the compilation product for an upload workspace, if it exists. + + Parameters + ---------- + source_id : int + Unique identifier for the upload workspace. + checksum : str + State up of the upload workspace. + output_format : :class:`.Format` + Defaults to :attr:`.Format.PDF`. + + Returns + ------- + :class:`CompilationProduct` + The compilation product itself. + + """ + endpoint = f'/{source_id}/{checksum}/{output_format.value}/product' + response = self.request('get', endpoint, token, stream=True) + return CompilationProduct(content_type=output_format.content_type, + stream=io.BytesIO(response.content)) + + def get_log(self, source_id: str, checksum: str, token: str, + output_format: Compilation.Format = PDF) -> CompilationLog: + """ + Get the compilation log for an upload workspace, if it exists. + + Parameters + ---------- + source_id : int + Unique identifier for the upload workspace. + checksum : str + State up of the upload workspace. + output_format : :class:`.Format` + Defaults to :attr:`.Format.PDF`. + + Returns + ------- + :class:`CompilationProduct` + The compilation product itself. + + """ + endpoint = f'/{source_id}/{checksum}/{output_format.value}/log' + response = self.request('get', endpoint, token, stream=True) + return CompilationLog(stream=io.BytesIO(response.content)) + + +def get_task_id(source_id: str, checksum: str, + output_format: Compilation.Format) -> str: + """Generate a key for a /checksum/format combination.""" + return f"{source_id}/{checksum}/{output_format.value}" + + +def split_task_id(task_id: str) -> Tuple[str, str, Compilation.Format]: + source_id, checksum, format_value = task_id.split("/") + return source_id, checksum, Compilation.Format(format_value) + + +class Download(object): + """Wrapper around response content.""" + + def __init__(self, response: requests.Response) -> None: + """Initialize with a :class:`requests.Response` object.""" + self._response = response + + def read(self, *args: Any, **kwargs: Any) -> bytes: + """Read response content.""" + return self._response.content diff --git a/src/arxiv/submission/services/compiler/tests.py b/src/arxiv/submission/services/compiler/tests.py new file mode 100644 index 0000000..1c135b9 --- /dev/null +++ b/src/arxiv/submission/services/compiler/tests.py @@ -0,0 +1,237 @@ +"""Tests for :mod:`.compiler`.""" + +from unittest import TestCase, mock + +from flask import Flask + +from arxiv.integration.api import status, exceptions + +from . import compiler +from ... import domain + + +class TestRequestCompilation(TestCase): + """Tests for :mod:`compiler.compile` with mocked responses.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'COMPILER_ENDPOINT': 'http://foohost:1234', + 'COMPILER_VERIFY': False + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_compile(self, mock_Session): + """Request compilation of an upload workspace.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + location = f'http://asdf/{source_id}/{checksum}/{output_format.value}' + in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value + mock_session = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.ACCEPTED, + json=mock.MagicMock(return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': in_progress + }), + headers={'Location': location} + ) + ), + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': domain.compilation.Compilation.Status.IN_PROGRESS.value + }), + headers={'Location': location} + ) + ) + ) + mock_Session.return_value = mock_session + + with self.app.app_context(): + cp = compiler.Compiler.current_session() + stat = cp.compile(source_id, checksum, 'footok', 'theLabel', + 'http://the.link') + self.assertEqual(stat.source_id, source_id) + self.assertEqual(stat.identifier, + f"{source_id}/{checksum}/{output_format.value}") + self.assertEqual(stat.status, + domain.compilation.Compilation.Status.IN_PROGRESS) + self.assertEqual(mock_session.post.call_count, 1) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_compile_redirects(self, mock_Session): + """Request compilation of an upload workspace already processing.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value + location = f'http://asdf/{source_id}/{checksum}/{output_format.value}' + mock_session = mock.MagicMock( + post=mock.MagicMock( # Redirected + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': in_progress + } + ) + ) + ) + ) + mock_Session.return_value = mock_session + with self.app.app_context(): + cp = compiler.Compiler.current_session() + stat = cp.compile(source_id, checksum, 'footok', 'theLabel', + 'http://the.link') + self.assertEqual(stat.source_id, source_id) + self.assertEqual(stat.identifier, + f"{source_id}/{checksum}/{output_format.value}") + self.assertEqual(stat.status, + domain.compilation.Compilation.Status.IN_PROGRESS) + self.assertEqual(mock_session.post.call_count, 1) + + +class TestGetTaskStatus(TestCase): + """Tests for :mod:`compiler.get_status` with mocked responses.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'COMPILER_ENDPOINT': 'http://foohost:1234', + 'COMPILER_VERIFY': False + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_get_status_failed(self, mock_Session): + """Get the status of a failed task.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + failed = domain.compilation.Compilation.Status.FAILED.value + mock_session = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': failed + } + ) + ) + ) + ) + mock_Session.return_value = mock_session + with self.app.app_context(): + cp = compiler.Compiler.current_session() + stat = cp.get_status(source_id, checksum, 'tok', output_format) + self.assertEqual(stat.source_id, source_id) + self.assertEqual(stat.identifier, + f"{source_id}/{checksum}/{output_format.value}") + self.assertEqual(stat.status, + domain.compilation.Compilation.Status.FAILED) + self.assertEqual(mock_session.get.call_count, 1) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_get_status_in_progress(self, mock_Session): + """Get the status of an in-progress task.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value + mock_session = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': in_progress + } + ) + ) + ) + ) + mock_Session.return_value = mock_session + with self.app.app_context(): + cp = compiler.Compiler.current_session() + stat = cp.get_status(source_id, checksum, 'tok', output_format) + self.assertEqual(stat.source_id, source_id) + self.assertEqual(stat.identifier, + f"{source_id}/{checksum}/{output_format.value}") + self.assertEqual(stat.status, + domain.compilation.Compilation.Status.IN_PROGRESS) + self.assertEqual(mock_session.get.call_count, 1) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_get_status_completed(self, mock_Session): + """Get the status of a completed task.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + succeeded = domain.compilation.Compilation.Status.SUCCEEDED.value + mock_session = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={ + 'source_id': source_id, + 'checksum': checksum, + 'output_format': output_format.value, + 'status': succeeded + } + ) + ) + ) + ) + mock_Session.return_value = mock_session + with self.app.app_context(): + cp = compiler.Compiler.current_session() + stat = cp.get_status(source_id, checksum, 'tok', output_format) + self.assertEqual(stat.source_id, source_id) + self.assertEqual(stat.identifier, + f"{source_id}/{checksum}/{output_format.value}") + self.assertEqual(stat.status, + domain.compilation.Compilation.Status.SUCCEEDED) + self.assertEqual(mock_session.get.call_count, 1) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_get_status_doesnt_exist(self, mock_Session): + """Get the status of a task that does not exist.""" + source_id = 42 + checksum = 'asdf1234=' + output_format = domain.compilation.Compilation.Format.PDF + mock_session = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.NOT_FOUND, + json=mock.MagicMock( + return_value={} + ) + ) + ) + ) + mock_Session.return_value = mock_session + with self.app.app_context(): + cp = compiler.Compiler.current_session() + with self.assertRaises(exceptions.NotFound): + cp.get_status(source_id, checksum, 'footok', output_format) diff --git a/src/arxiv/submission/services/filemanager/__init__.py b/src/arxiv/submission/services/filemanager/__init__.py new file mode 100644 index 0000000..eca5ee8 --- /dev/null +++ b/src/arxiv/submission/services/filemanager/__init__.py @@ -0,0 +1,3 @@ +"""Integration with the file manager service.""" + +from .filemanager import Filemanager \ No newline at end of file diff --git a/src/arxiv/submission/services/filemanager/filemanager.py b/src/arxiv/submission/services/filemanager/filemanager.py new file mode 100644 index 0000000..c700275 --- /dev/null +++ b/src/arxiv/submission/services/filemanager/filemanager.py @@ -0,0 +1,336 @@ +"""Provides an integration with the file management service.""" + +from collections import defaultdict +from http import HTTPStatus as status +from typing import Tuple, List, IO, Mapping, Any + +import dateutil.parser +from werkzeug.datastructures import FileStorage + +from arxiv.base import logging +from arxiv.base.globals import get_application_config +from arxiv.integration.api import service + +from ...domain import SubmissionContent +from ...domain.uploads import Upload, FileStatus, FileError, UploadStatus, \ + UploadLifecycleStates +from ..util import ReadWrapper + +logger = logging.getLogger(__name__) + + +class Filemanager(service.HTTPIntegration): + """Encapsulates a connection with the file management service.""" + + SERVICE = 'filemanager' + VERSION = '37bf8d3' + + class Meta: + """Configuration for :class:`FileManager`.""" + + service_name = "filemanager" + + def has_single_file(self, upload_id: str, token: str, + file_type: str = 'PDF') -> bool: + """Checj whether an upload workspace one or more file of a type.""" + stat = self.get_upload_status(upload_id, token) + try: + next((f.name for f in stat.files if f.file_type == file_type)) + except StopIteration: # Empty iterator => no such file. + return False + return True + + def get_single_file(self, upload_id: str, token: str, + file_type: str = 'PDF') -> Tuple[IO[bytes], str, str]: + """ + Get a single PDF file from the submission package. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + token : str + Auth token to include in the request. + + Returns + ------- + bool + ``True`` if the source package consists of a single file. ``False`` + otherwise. + str + The checksum of the source package. + str + The checksum of the single file. + + """ + stat = self.get_upload_status(upload_id, token) + try: + pdf_name = next((f.name for f in stat.files + if f.file_type == file_type)) + except StopIteration as e: + raise RuntimeError(f'No single `{file_type}` file found.') from e + content, headers = self.get_file_content(upload_id, pdf_name, token) + if stat.checksum is None: + raise RuntimeError(f'Upload workspace checksum not set') + return content, stat.checksum, headers['ETag'] + + def is_available(self, **kwargs: Any) -> bool: + """Check our connection to the filemanager service.""" + config = get_application_config() + status_endpoint = config.get('FILEMANAGER_STATUS_ENDPOINT', 'status') + timeout: float = kwargs.get('timeout', 0.2) + try: + response = self.request('get', status_endpoint, timeout=timeout) + return bool(response.status_code == 200) + except Exception as e: + logger.error('Error when calling filemanager: %s', e) + return False + return True + + def _parse_upload_status(self, data: dict) -> Upload: + file_errors: Mapping[str, List[FileError]] = defaultdict(list) + non_file_errors = [] + filepaths = [fdata['public_filepath'] for fdata in data['files']] + for etype, filepath, message in data['errors']: + if filepath and filepath in filepaths: + file_errors[filepath].append(FileError(etype.upper(), message)) + else: # This includes messages for files that were removed. + non_file_errors.append(FileError(etype.upper(), message)) + + + return Upload( + started=dateutil.parser.parse(data['start_datetime']), + completed=dateutil.parser.parse(data['completion_datetime']), + created=dateutil.parser.parse(data['created_datetime']), + modified=dateutil.parser.parse(data['modified_datetime']), + status=UploadStatus(data['readiness']), + lifecycle=UploadLifecycleStates(data['upload_status']), + locked=bool(data['lock_state'] == 'LOCKED'), + identifier=data['upload_id'], + files=[ + FileStatus( + name=fdata['name'], + path=fdata['public_filepath'], + size=fdata['size'], + file_type=fdata['type'], + modified=dateutil.parser.parse(fdata['modified_datetime']), + errors=file_errors[fdata['public_filepath']] + ) for fdata in data['files'] + ], + errors=non_file_errors, + compressed_size=data['upload_compressed_size'], + size=data['upload_total_size'], + checksum=data['checksum'], + source_format=SubmissionContent.Format(data['source_format']) + ) + + def request_file(self, path: str, token: str) -> Tuple[IO[bytes], dict]: + """Perform a GET request for a file, and handle any exceptions.""" + response = self.request('get', path, token, stream=True) + stream = ReadWrapper(response.iter_content, + int(response.headers['Content-Length'])) + return stream, response.headers + + def upload_package(self, pointer: FileStorage, token: str) -> Upload: + """ + Stream an upload to the file management service. + + If the file is an archive (zip, tar-ball, etc), it will be unpacked. + A variety of processing and sanitization routines are performed, and + any errors or warnings (including deleted files) will be included in + the response body. + + Parameters + ---------- + pointer : :class:`FileStorage` + File upload stream from the client. + token : str + Auth token to include in the request. + + Returns + ------- + dict + A description of the upload package. + dict + Response headers. + + """ + files = {'file': (pointer.filename, pointer, pointer.mimetype)} + data, _, _ = self.json('post', '/', token, files=files, + expected_code=[status.CREATED, + status.OK], + timeout=30, allow_2xx_redirects=False) + return self._parse_upload_status(data) + + def get_upload_status(self, upload_id: str, token: str) -> Upload: + """ + Retrieve metadata about an accepted and processed upload package. + + Parameters + ---------- + upload_id : int + Unique long-lived identifier for the upload. + token : str + Auth token to include in the request. + + Returns + ------- + dict + A description of the upload package. + dict + Response headers. + + """ + data, _, _ = self.json('get', f'/{upload_id}', token) + return self._parse_upload_status(data) + + def add_file(self, upload_id: str, pointer: FileStorage, token: str, + ancillary: bool = False) -> Upload: + """ + Upload a file or package to an existing upload workspace. + + If the file is an archive (zip, tar-ball, etc), it will be unpacked. A + variety of processing and sanitization routines are performed. Existing + files will be overwritten by files of the same name. and any errors or + warnings (including deleted files) will be included in the response + body. + + Parameters + ---------- + upload_id : int + Unique long-lived identifier for the upload. + pointer : :class:`FileStorage` + File upload stream from the client. + token : str + Auth token to include in the request. + ancillary : bool + If ``True``, the file should be added as an ancillary file. + + Returns + ------- + dict + A description of the upload package. + dict + Response headers. + + """ + files = {'file': (pointer.filename, pointer, pointer.mimetype)} + data, _, _ = self.json('post', f'/{upload_id}', token, + data={'ancillary': ancillary}, files=files, + expected_code=[status.CREATED, status.OK], + timeout=30, allow_2xx_redirects=False) + return self._parse_upload_status(data) + + def delete_all(self, upload_id: str, token: str) -> Upload: + """ + Delete all files in the workspace. + + Does not delete the workspace itself. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + token : str + Auth token to include in the request. + + """ + data, _, _ = self.json('post', f'/{upload_id}/delete_all', token) + return self._parse_upload_status(data) + + def get_file_content(self, upload_id: str, file_path: str, token: str) \ + -> Tuple[IO[bytes], dict]: + """ + Get the content of a single file from the upload workspace. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + file_path : str + Path-like key for individual file in upload workspace. This is the + path relative to the root of the workspace. + token : str + Auth token to include in the request. + + Returns + ------- + :class:`ReadWrapper` + A ``read() -> bytes``-able wrapper around response content. + dict + Response headers. + + """ + return self.request_file(f'/{upload_id}/{file_path}/content', token) + + def delete_file(self, upload_id: str, file_path: str, token: str) \ + -> Upload: + """ + Delete a single file from the upload workspace. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + file_path : str + Path-like key for individual file in upload workspace. This is the + path relative to the root of the workspace. + token : str + Auth token to include in the request. + + Returns + ------- + dict + An empty dict. + dict + Response headers. + + """ + data, _, _ = self.json('delete', f'/{upload_id}/{file_path}', token) + return self._parse_upload_status(data) + + def get_upload_content(self, upload_id: str, token: str) \ + -> Tuple[IO[bytes], dict]: + """ + Retrieve the sanitized/processed upload package. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + token : str + Auth token to include in the request. + + Returns + ------- + :class:`ReadWrapper` + A ``read() -> bytes``-able wrapper around response content. + dict + Response headers. + + """ + return self.request_file(f'/{upload_id}/content', token) + + def get_logs(self, upload_id: str, token: str) -> Tuple[dict, dict]: + """ + Retrieve log files related to upload workspace. + + Indicates history or actions on workspace. + + Parameters + ---------- + upload_id : str + Unique long-lived identifier for the upload. + token : str + Auth token to include in the request. + + Returns + ------- + dict + Log data for the upload workspace. + dict + Response headers. + + """ + data, _, headers = self.json('post', f'/{upload_id}/logs', token) + return data, headers diff --git a/src/arxiv/submission/services/filemanager/tests/__init__.py b/src/arxiv/submission/services/filemanager/tests/__init__.py new file mode 100644 index 0000000..4f51240 --- /dev/null +++ b/src/arxiv/submission/services/filemanager/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for mod:`submit.services`.""" diff --git a/src/arxiv/submission/services/filemanager/tests/data/test.txt b/src/arxiv/submission/services/filemanager/tests/data/test.txt new file mode 100644 index 0000000..f0c1996 --- /dev/null +++ b/src/arxiv/submission/services/filemanager/tests/data/test.txt @@ -0,0 +1,9 @@ +Bacon ipsum dolor amet venison pastrami short ribs pork belly, voluptate non labore mollit landjaeger. Ex porchetta ground round strip steak fatback turducken boudin pork chop ham. Bresaola eiusmod prosciutto fatback flank exercitation short ribs dolore meatball. Do fatback quis culpa in, andouille landjaeger ut pancetta excepteur ipsum turkey. + +Culpa pork belly boudin eiusmod. Pariatur boudin officia nostrud turkey ham esse laborum alcatra chicken ad drumstick beef. Spare ribs turkey t-bone voluptate, magna ham hock biltong ut pancetta ut in ribeye alcatra dolore landjaeger. Andouille velit salami spare ribs aliquip anim shankle pork belly burgdoggen. + +Pariatur laboris beef ribs biltong, ham hock ground round cillum jerky. Ball tip t-bone elit, bacon et bresaola velit sint tri-tip ipsum. Dolore commodo capicola, anim drumstick landjaeger nisi filet mignon enim in. Incididunt minim swine deserunt dolore. + +Salami aute et tail laboris ipsum ea. Pancetta aliquip alcatra porchetta velit fugiat laborum picanha. Cupidatat reprehenderit do, pastrami sirloin pork loin ipsum tail. Velit id alcatra adipisicing, short loin aliqua prosciutto tail flank cillum capicola. Reprehenderit irure commodo proident kevin cillum short loin sunt shoulder minim burgdoggen aute pastrami ut fatback. + +Non shankle anim incididunt tenderloin, eiusmod fatback landjaeger alcatra salami rump kevin strip steak. Eu cillum pancetta, filet mignon velit sirloin picanha ullamco laboris consectetur esse veniam sausage. Aliqua shoulder nisi occaecat porchetta tri-tip, esse ribeye fugiat pork chop flank. Strip steak dolore aliqua brisket labore sunt pig. diff --git a/src/arxiv/submission/services/filemanager/tests/data/test.zip b/src/arxiv/submission/services/filemanager/tests/data/test.zip new file mode 100644 index 0000000000000000000000000000000000000000..f0f9e77243e698360f5a6cd7a044ee136ccf56c4 GIT binary patch literal 896 zcmWIWW@Zs#U|`^2*yAGNJ9YZz4Y!yW7&6&FA~Fmmsl_FFB^4#1A)E}%%G;`<)quFP zf}4Sn@a*uj`{pK@5?!TU|jWhN?IWx0wVTJc^^;fUk{2#W;%~3bwekzn} z*j+KpY2&N!)1FD1ReiF$G+m>!Iax?IlXu!SiAfhH6AF4R3O|e3!WG3u$}XzTQu$J{mByP=H8-B-_|m2EK>elNoHQKqqw_8XyG&t>7)qBsu zwP$J1y16w+%`RLq>Yw88-e;BZb*8{^|F3SJH$7WaXqwoX+{lxkRgiq6GG^8NO^qi5 zt5!HShd#Fw4?4d}{$+grl~ua?_GLN!S>KSV>bb`L-6^Mr_}z6e29YZ!N8Vj|be_?} zS@ZKQYvz@|n6-88&BbRfd_Kc(v7vs^f$N)ArY>n*loL4b(OQ?a7Hzj*WS2aYIP`7p zbD0Hhzt)=uRDVlbEiGaY<$KG0U2K-bPrFsUSM8SH-R696VqM*V#T)PVIB;%d+9_7) z`mpZ%HLXzHk1;aq%9pOMdAn-f7lDs#bACQ(|2JrweOptg0 literal 0 HcmV?d00001 diff --git a/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py b/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py new file mode 100644 index 0000000..310890f --- /dev/null +++ b/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py @@ -0,0 +1,255 @@ +import io +import os +import subprocess +import time +from unittest import TestCase, mock + +import docker +from arxiv_auth.auth import scopes +from arxiv_auth.helpers import generate_token +from flask import Flask, Config +from werkzeug.datastructures import FileStorage + +from arxiv.integration.api import exceptions + +from ..filemanager import Filemanager +from ....domain.uploads import Upload, FileStatus, FileError, UploadStatus, \ + UploadLifecycleStates + +mock_app = Flask('test') +mock_app.config.update({ + 'FILEMANAGER_ENDPOINT': 'http://localhost:8003/filemanager/api', + 'FILEMANAGER_VERIFY': False +}) +Filemanager.init_app(mock_app) + + +class TestFilemanagerIntegration(TestCase): + + __test__ = int(bool(os.environ.get('WITH_INTEGRATION', False))) + + @classmethod + def setUpClass(cls): + """Start up the file manager service.""" + print('starting file management service') + client = docker.from_env() + image = f'arxiv/{Filemanager.SERVICE}' + # client.images.pull(image, tag=Filemanager.VERSION) + + cls.data = client.volumes.create(name='data', driver='local') + cls.filemanager = client.containers.run( + f'{image}:{Filemanager.VERSION}', + detach=True, + ports={'8000/tcp': 8003}, + volumes={'data': {'bind': '/data', 'mode': 'rw'}}, + environment={ + 'NAMESPACE': 'test', + 'JWT_SECRET': 'foosecret', + 'SQLALCHEMY_DATABASE_URI': 'sqlite:////opt/arxiv/foo.db', + 'STORAGE_BASE_PATH': '/data' + }, + command='/bin/bash -c "python bootstrap.py && uwsgi --ini /opt/arxiv/uwsgi.ini"' + ) + + time.sleep(5) + + os.environ['JWT_SECRET'] = 'foosecret' + cls.token = generate_token('1', 'u@ser.com', 'theuser', + scope=[scopes.WRITE_UPLOAD, + scopes.READ_UPLOAD]) + + @classmethod + def tearDownClass(cls): + """Tear down file management service once all tests have run.""" + cls.filemanager.kill() + cls.filemanager.remove() + cls.data.remove() + + def setUp(self): + """Create a new app for config and context.""" + self.app = Flask('test') + self.app.config.update({ + 'FILEMANAGER_ENDPOINT': 'http://localhost:8003', + }) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_upload_package(self): + """Upload a new package.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + data = fm.upload_package(pointer, self.token) + self.assertIsInstance(data, Upload) + self.assertEqual(data.status, UploadStatus.ERRORS) + self.assertEqual(data.lifecycle, UploadLifecycleStates.ACTIVE) + self.assertFalse(data.locked) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_upload_package_without_authorization(self): + """Upload a new package without authorization.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + token = generate_token('1', 'u@ser.com', 'theuser', + scope=[scopes.READ_UPLOAD]) + with self.assertRaises(exceptions.RequestForbidden): + fm.upload_package(pointer, token) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_upload_package_without_authentication_token(self): + """Upload a new package without an authentication token.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + with self.assertRaises(exceptions.RequestUnauthorized): + fm.upload_package(pointer, '') + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_get_upload_status(self): + """Get the status of an upload.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + data = fm.upload_package(pointer, self.token) + + status = fm.get_upload_status(data.identifier, self.token) + self.assertIsInstance(status, Upload) + self.assertEqual(status.status, UploadStatus.ERRORS) + self.assertEqual(status.lifecycle, UploadLifecycleStates.ACTIVE) + self.assertFalse(status.locked) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_get_upload_status_without_authorization(self): + """Get the status of an upload without the right scope.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + token = generate_token('1', 'u@ser.com', 'theuser', + scope=[scopes.WRITE_UPLOAD]) + data = fm.upload_package(pointer, self.token) + + with self.assertRaises(exceptions.RequestForbidden): + fm.get_upload_status(data.identifier, token) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_get_upload_status_nacho_upload(self): + """Get the status of someone elses' upload.""" + fm = Filemanager.current_session() + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + + data = fm.upload_package(pointer, self.token) + + token = generate_token('2', 'other@ser.com', 'theotheruser', + scope=[scopes.READ_UPLOAD]) + with self.assertRaises(exceptions.RequestForbidden): + fm.get_upload_status(data.identifier, token) + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_add_file_to_upload(self): + """Add a file to an existing upload workspace.""" + fm = Filemanager.current_session() + + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.zip') + pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', + content_type='application/tar+gz') + data = fm.upload_package(pointer, self.token) + + fpath2 = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.txt') + pointer2 = FileStorage(open(fpath2, 'rb'), filename='test.txt', + content_type='text/plain') + + @mock.patch('arxiv.integration.api.service.current_app', mock_app) + def test_pdf_only_upload(self): + """Upload a PDF.""" + fm = Filemanager.current_session() + + fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], + 'data', 'test.pdf') + pointer = FileStorage(io.BytesIO(MINIMAL_PDF.encode('utf-8')), + filename='test.pdf', + content_type='application/pdf') + data = fm.upload_package(pointer, self.token) + upload_id = data.identifier + content, source_chex, file_chex = fm.get_single_file(upload_id, self.token) + self.assertEqual(source_chex, data.checksum) + self.assertEqual(len(content.read()), len(MINIMAL_PDF.encode('utf-8')), + 'Size of the original content is preserved') + self.assertEqual(file_chex, 'Copxu8SRHajXOfeK8_1h7w==') + + +# From https://brendanzagaeski.appspot.com/0004.html +MINIMAL_PDF = """ +%PDF-1.1 +%¥±ë + +1 0 obj + << /Type /Catalog + /Pages 2 0 R + >> +endobj + +2 0 obj + << /Type /Pages + /Kids [3 0 R] + /Count 1 + /MediaBox [0 0 300 144] + >> +endobj + +3 0 obj + << /Type /Page + /Parent 2 0 R + /Resources + << /Font + << /F1 + << /Type /Font + /Subtype /Type1 + /BaseFont /Times-Roman + >> + >> + >> + /Contents 4 0 R + >> +endobj + +4 0 obj + << /Length 55 >> +stream + BT + /F1 18 Tf + 0 0 Td + (Hello World) Tj + ET +endstream +endobj + +xref +0 5 +0000000000 65535 f +0000000018 00000 n +0000000077 00000 n +0000000178 00000 n +0000000457 00000 n +trailer + << /Root 1 0 R + /Size 5 + >> +startxref +565 +%%EOF +""" \ No newline at end of file diff --git a/src/arxiv/submission/services/plaintext/__init__.py b/src/arxiv/submission/services/plaintext/__init__.py new file mode 100644 index 0000000..5426f7c --- /dev/null +++ b/src/arxiv/submission/services/plaintext/__init__.py @@ -0,0 +1,3 @@ +"""Service integration module for plain text extraction.""" + +from .plaintext import PlainTextService, ExtractionFailed diff --git a/src/arxiv/submission/services/plaintext/plaintext.py b/src/arxiv/submission/services/plaintext/plaintext.py new file mode 100644 index 0000000..cc0d361 --- /dev/null +++ b/src/arxiv/submission/services/plaintext/plaintext.py @@ -0,0 +1,167 @@ +""" +Provides integration with the plaintext extraction service. + +This integration is focused on usage patterns required by the submission +system. Specifically: + +1. Must be able to request an extraction for a compiled submission. +2. Must be able to poll whether the extraction has completed. +3. Must be able to retrieve the raw binary content from when the extraction + has finished successfully. +4. Encounter an informative exception if something goes wrong. + +This represents only a subset of the functionality provided by the plaintext +service itself. +""" + +from enum import Enum +from typing import Any, IO + +from arxiv.base import logging +from arxiv.integration.api import status, exceptions, service +from arxiv.taxonomy import Category + +from ..util import ReadWrapper + +logger = logging.getLogger(__name__) + + +class ExtractionFailed(exceptions.RequestFailed): + """The plain text extraction service failed to extract text.""" + + +class ExtractionInProgress(exceptions.RequestFailed): + """An extraction is already in progress.""" + + +class PlainTextService(service.HTTPIntegration): + """Represents an interface to the plain text extraction service.""" + + SERVICE = 'plaintext' + VERSION = '0.4.1rc1' + """Version of the service for which this module is implemented.""" + + class Meta: + """Configuration for :class:`Classifier`.""" + + service_name = "plaintext" + + class Status(Enum): + """Task statuses.""" + + IN_PROGRESS = 'in_progress' + SUCCEEDED = 'succeeded' + FAILED = 'failed' + + @property + def _base_endpoint(self) -> str: + return f'{self._scheme}://{self._host}:{self._port}' + + def is_available(self, **kwargs: Any) -> bool: + """Check our connection to the plain text service.""" + timeout: float = kwargs.get('timeout', 0.5) + try: + response = self.request('head', '/status', timeout=timeout) + except Exception as e: + logger.error('Encountered error calling plain text service: %s', e) + return False + if response.status_code != status.OK: + logger.error('Got unexpected status: %s', response.status_code) + return False + return True + + def endpoint(self, source_id: str, checksum: str) -> str: + """Get the URL of the extraction endpoint.""" + return f'/submission/{source_id}/{checksum}' + + def status_endpoint(self, source_id: str, checksum: str) -> str: + """Get the URL of the extraction status endpoint.""" + return f'/submission/{source_id}/{checksum}/status' + + def request_extraction(self, source_id: str, checksum: str, + token: str) -> None: + """ + Make a request for plaintext extraction using the source ID. + + Parameters + ---------- + source_id : str + ID of the submission upload workspace. + + """ + expected_code = [status.OK, status.ACCEPTED, + status.SEE_OTHER] + response = self.request('post', self.endpoint(source_id, checksum), + token, expected_code=expected_code) + if response.status_code == status.SEE_OTHER: + raise ExtractionInProgress('Extraction already exists', response) + elif response.status_code not in expected_code: + raise exceptions.RequestFailed('Unexpected status', response) + return + + def extraction_is_complete(self, source_id: str, checksum: str, + token: str) -> bool: + """ + Check the status of an extraction task by submission upload ID. + + Parameters + ---------- + source_id : str + ID of the submission upload workspace. + + Returns + ------- + bool + + Raises + ------ + :class:`ExtractionFailed` + Raised if the task is in a failed state, or an unexpected condition + is encountered. + + """ + endpoint = self.status_endpoint(source_id, checksum) + expected_code = [status.OK, status.SEE_OTHER] + response = self.request('get', endpoint, token, allow_redirects=False, + expected_code=expected_code) + data = response.json() + if response.status_code == status.SEE_OTHER: + return True + elif self.Status(data['status']) is self.Status.IN_PROGRESS: + return False + elif self.Status(data['status']) is self.Status.FAILED: + raise ExtractionFailed('Extraction failed', response) + raise ExtractionFailed('Unexpected state', response) + + def retrieve_content(self, source_id: str, checksum: str, + token: str) -> IO[bytes]: + """ + Retrieve plain text content by submission upload ID. + + Parameters + ---------- + source_id : str + ID of the submission upload workspace. + + Returns + ------- + :class:`io.BytesIO` + Raw content stream. + + Raises + ------ + :class:`RequestFailed` + Raised if an unexpected status was encountered. + :class:`ExtractionInProgress` + Raised if an extraction is currently in progress + + """ + expected_code = [status.OK, status.SEE_OTHER] + response = self.request('get', self.endpoint(source_id, checksum), + token, expected_code=expected_code, + headers={'Accept': 'text/plain'}) + if response.status_code == status.SEE_OTHER: + raise ExtractionInProgress('Extraction is in progress', response) + stream = ReadWrapper(response.iter_content, + int(response.headers['Content-Length'])) + return stream diff --git a/src/arxiv/submission/services/plaintext/tests.py b/src/arxiv/submission/services/plaintext/tests.py new file mode 100644 index 0000000..7349f23 --- /dev/null +++ b/src/arxiv/submission/services/plaintext/tests.py @@ -0,0 +1,827 @@ +"""Tests for :mod:`arxiv.submission.services.plaintext`.""" + +import io +import os +import tempfile +import time +from unittest import TestCase, mock +from threading import Thread + +import docker +from flask import Flask, send_file + +from arxiv.integration.api import exceptions, status +from ...tests.util import generate_token +from . import plaintext + + +class TestPlainTextService(TestCase): + """Tests for :class:`.plaintext.PlainTextService`.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'PLAINTEXT_ENDPOINT': 'http://foohost:5432', + 'PLAINTEXT_VERIFY': False + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_already_in_progress(self, mock_Session): + """A plaintext extraction is already in progress.""" + mock_post = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.SEE_OTHER, + json=mock.MagicMock(return_value={}), + headers={'Location': '...'} + ) + ) + mock_Session.return_value = mock.MagicMock(post=mock_post) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionInProgress): + service.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction(self, mock_Session): + """Extraction is successfully requested.""" + mock_session = mock.MagicMock(**{ + 'post': mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.ACCEPTED, + json=mock.MagicMock(return_value={}), + content='', + headers={'Location': '/somewhere'} + ) + ), + 'get': mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={'reason': 'extraction in process'} + ), + content="{'reason': 'fulltext extraction in process'}", + headers={} + ) + ) + }) + mock_Session.return_value = mock_session + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + self.assertIsNone( + service.request_extraction(source_id, 'foochex==', 'footoken') + ) + self.assertEqual( + mock_session.post.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==' + ) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction_bad_request(self, mock_Session): + """Service returns 400 Bad Request.""" + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.BAD_REQUEST, + json=mock.MagicMock(return_value={ + 'reason': 'something is not quite right' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.BadRequest): + service.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction_server_error(self, mock_Session): + """Service returns 500 Internal Server Error.""" + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.INTERNAL_SERVER_ERROR, + json=mock.MagicMock(return_value={ + 'reason': 'something is not quite right' + }) + ) + ) + ) + source_id = '132456' + + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestFailed): + service.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.UNAUTHORIZED, + json=mock.MagicMock(return_value={ + 'reason': 'who are you' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + service.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = mock.MagicMock( + post=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.FORBIDDEN, + json=mock.MagicMock(return_value={ + 'reason': 'you do not have sufficient authz' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + service.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_is_complete(self, mock_Session): + """Extraction is indeed complete.""" + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.SEE_OTHER, + json=mock.MagicMock(return_value={}), + headers={'Location': '...'} + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + self.assertTrue( + svc.extraction_is_complete(source_id, 'foochex==', 'footoken') + ) + self.assertEqual( + mock_get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==/status' + ) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_in_progress(self, mock_Session): + """Extraction is still in progress.""" + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value={'status': 'in_progress'}) + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + self.assertFalse( + svc.extraction_is_complete(source_id, 'foochex==', 'footoken') + ) + self.assertEqual( + mock_get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==/status' + ) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_failed(self, mock_Session): + """Extraction failed.""" + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock(return_value={'status': 'failed'}) + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionFailed): + svc.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_complete_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.UNAUTHORIZED, + json=mock.MagicMock(return_value={ + 'reason': 'who are you' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + svc.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_complete_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.FORBIDDEN, + json=mock.MagicMock(return_value={ + 'reason': 'you do not have sufficient authz' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + svc.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.UNAUTHORIZED, + json=mock.MagicMock(return_value={ + 'reason': 'who are you' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + svc.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = mock.MagicMock( + get=mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.FORBIDDEN, + json=mock.MagicMock(return_value={ + 'reason': 'you do not have sufficient authz' + }) + ) + ) + ) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + service.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve(self, mock_Session): + """Retrieval is successful.""" + content = b'thisisthecontent' + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + iter_content=lambda size: iter([content]) + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + svc = plaintext.PlainTextService.current_session() + rcontent = svc.retrieve_content(source_id, 'foochex==', 'footoken') + self.assertEqual(rcontent.read(), content, + "Returns binary content as received") + self.assertEqual(mock_get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_nonexistant(self, mock_Session): + """There is no such plaintext resource.""" + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.NOT_FOUND, + json=mock.MagicMock(return_value={'reason': 'no such thing'}) + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.NotFound): + service.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_in_progress(self, mock_Session): + """There is no such plaintext resource.""" + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.SEE_OTHER, + json=mock.MagicMock(return_value={}), + headers={'Location': '...'} + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + service = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionInProgress): + service.retrieve_content(source_id, 'foochex==', 'footoken') + + +class TestPlainTextServiceModule(TestCase): + """Tests for :mod:`.services.plaintext`.""" + + def setUp(self): + """Create an app for context.""" + self.app = Flask('test') + self.app.config.update({ + 'PLAINTEXT_ENDPOINT': 'http://foohost:5432', + 'PLAINTEXT_VERIFY': False + }) + + def session(self, status_code=status.OK, method="get", json={}, + content="", headers={}): + """Make a mock session.""" + return mock.MagicMock(**{ + method: mock.MagicMock( + return_value=mock.MagicMock( + status_code=status_code, + json=mock.MagicMock( + return_value=json + ), + iter_content=lambda size: iter([content]), + headers=headers + ) + ) + }) + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_already_in_progress(self, mock_Session): + """A plaintext extraction is already in progress.""" + mock_Session.return_value = self.session( + status_code=status.SEE_OTHER, + method='post', + headers={'Location': '...'} + ) + + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionInProgress): + pt.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction(self, mock_Session): + """Extraction is successfully requested.""" + mock_session = mock.MagicMock(**{ + 'post': mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.ACCEPTED, + json=mock.MagicMock(return_value={}), + content='', + headers={'Location': '/somewhere'} + ) + ), + 'get': mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + json=mock.MagicMock( + return_value={'reason': 'extraction in process'} + ), + content="{'reason': 'fulltext extraction in process'}", + headers={} + ) + ) + }) + mock_Session.return_value = mock_session + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertIsNone( + pt.request_extraction(source_id, 'foochex==', 'footoken') + ) + self.assertEqual(mock_session.post.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_bad_request(self, mock_Session): + """Service returns 400 Bad Request.""" + mock_Session.return_value = self.session( + status_code=status.BAD_REQUEST, + method='post', + json={'reason': 'something is not quite right'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.BadRequest): + pt.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_server_error(self, mock_Session): + """Service returns 500 Internal Server Error.""" + mock_Session.return_value = self.session( + status_code=status.INTERNAL_SERVER_ERROR, + method='post', + json={'reason': 'something is not quite right'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestFailed): + pt.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = self.session( + status_code=status.UNAUTHORIZED, + method='post', + json={'reason': 'who are you'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + pt.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_request_extraction_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = self.session( + status_code=status.FORBIDDEN, + method='post', + json={'reason': 'you do not have sufficient authz'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + pt.request_extraction(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_is_complete(self, mock_Session): + """Extraction is indeed complete.""" + mock_session = self.session( + status_code=status.SEE_OTHER, + headers={'Location': '...'} + ) + mock_Session.return_value = mock_session + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertTrue( + pt.extraction_is_complete(source_id, 'foochex==', 'footoken') + ) + self.assertEqual(mock_session.get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==/status') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_in_progress(self, mock_Session): + """Extraction is still in progress.""" + mock_session = self.session( + json={'status': 'in_progress'} + ) + mock_Session.return_value = mock_session + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertFalse( + pt.extraction_is_complete(source_id, 'foochex==', 'footoken') + ) + self.assertEqual(mock_session.get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==/status') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_extraction_failed(self, mock_Session): + """Extraction failed.""" + mock_Session.return_value = self.session(json={'status': 'failed'}) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionFailed): + pt.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_complete_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = self.session( + status_code=status.UNAUTHORIZED, + json={'reason': 'who are you'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + pt.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_complete_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = self.session( + status_code=status.FORBIDDEN, + json={'reason': 'you do not have sufficient authz'} + ) + source_id = '132456' + + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + pt.extraction_is_complete(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_unauthorized(self, mock_Session): + """Service returns 401 Unauthorized.""" + mock_Session.return_value = self.session( + status_code=status.UNAUTHORIZED, + json={'reason': 'who are you'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestUnauthorized): + pt.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_forbidden(self, mock_Session): + """Service returns 403 Forbidden.""" + mock_Session.return_value = self.session( + status_code=status.FORBIDDEN, + json={'reason': 'you do not have sufficient authz'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.RequestForbidden): + pt.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve(self, mock_Session): + """Retrieval is successful.""" + content = b'thisisthecontent' + mock_get = mock.MagicMock( + return_value=mock.MagicMock( + status_code=status.OK, + iter_content=lambda size: iter([content]) + ) + ) + mock_Session.return_value = mock.MagicMock(get=mock_get) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertEqual( + pt.retrieve_content(source_id, 'foochex==', 'footoken').read(), + content, + "Returns binary content as received" + ) + self.assertEqual(mock_get.call_args[0][0], + 'http://foohost:5432/submission/132456/foochex==') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_nonexistant(self, mock_Session): + """There is no such plaintext resource.""" + mock_Session.return_value = self.session( + status_code=status.NOT_FOUND, + json={'reason': 'no such thing'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(exceptions.NotFound): + pt.retrieve_content(source_id, 'foochex==', 'footoken') + + @mock.patch('arxiv.integration.api.service.requests.Session') + def test_retrieve_in_progress(self, mock_Session): + """There is no such plaintext resource.""" + mock_Session.return_value = self.session( + status_code=status.SEE_OTHER, + headers={'Location': '...'} + ) + source_id = '132456' + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + with self.assertRaises(plaintext.ExtractionInProgress): + pt.retrieve_content(source_id, 'foochex==', 'footoken') + + +class TestPlainTextServiceIntegration(TestCase): + """Integration tests for the plain text service.""" + + __test__ = bool(int(os.environ.get('WITH_INTEGRATION', '0'))) + + @classmethod + def setUpClass(cls): + """Start up the plain text service.""" + client = docker.from_env() + image = f'arxiv/{plaintext.PlainTextService.SERVICE}' + client.images.pull(image, tag=plaintext.PlainTextService.VERSION) + # client.images.pull('docker', tag='18-dind') + client.images.pull('redis') + + # Create a mock preview service, from which the plaintext service + # will retrieve a PDF. + cls.mock_preview = Flask('preview') + + # + @cls.mock_preview.route('///content', methods=['GET']) + def get_pdf(src, chx=None, fmt=None): + response = send_file(io.BytesIO(MINIMAL_PDF.encode('utf-8')), + mimetype='application/pdf') + response.headers['ARXIV-OWNER'] = src[0] + response.headers['ETag'] = 'footag==' + return response + + @cls.mock_preview.route('//', methods=['HEAD']) + @cls.mock_preview.route('///content', methods=['HEAD']) + def exists(src, chx=None, fmt=None): + return '', 200, {'ARXIV-OWNER': src[0], 'ETag': 'footag=='} + + def start_preview_app(): + cls.mock_preview.run('0.0.0.0', 5009) + + t = Thread(target=start_preview_app) + t.daemon = True + t.start() + + cls.network = client.networks.create('test-plaintext-network') + cls.data = client.volumes.create(name='data', driver='local') + + # This is the volume shared by the worker and the docker host. + cls.pdfs = tempfile.mkdtemp() + + cls.plaintext_api = client.containers.run( + f'{image}:{plaintext.PlainTextService.VERSION}', + detach=True, + network='test-plaintext-network', + ports={'8000/tcp': 8889}, + name='plaintext', + volumes={'data': {'bind': '/data', 'mode': 'rw'}, + cls.pdfs: {'bind': '/pdfs', 'mode': 'rw'}}, + environment={ + 'NAMESPACE': 'test', + 'REDIS_ENDPOINT': 'test-plaintext-redis:6379', + 'PREVIEW_ENDPOINT': 'http://host.docker.internal:5009', + 'JWT_SECRET': 'foosecret', + 'MOUNTDIR': cls.pdfs + }, + command=["uwsgi", "--ini", "/opt/arxiv/uwsgi.ini"] + ) + cls.redis = client.containers.run( + f'redis', + detach=True, + network='test-plaintext-network', + name='test-plaintext-redis' + ) + cls.plaintext_worker = client.containers.run( + f'{image}:{plaintext.PlainTextService.VERSION}', + detach=True, + network='test-plaintext-network', + volumes={'data': {'bind': '/data', 'mode': 'rw'}, + cls.pdfs: {'bind': '/pdfs', 'mode': 'rw'}, + '/var/run/docker.sock': {'bind': '/var/run/docker.sock', 'mode': 'rw'}}, + environment={ + 'NAMESPACE': 'test', + 'REDIS_ENDPOINT': 'test-plaintext-redis:6379', + 'DOCKER_HOST': 'unix://var/run/docker.sock', + 'PREVIEW_ENDPOINT': 'http://host.docker.internal:5009', + 'JWT_SECRET': 'foosecret', + 'MOUNTDIR': cls.pdfs + }, + command=["celery", "worker", "-A", "fulltext.worker.celery_app", + "--loglevel=INFO", "-E", "--concurrency=1"] + ) + time.sleep(5) + + cls.app = Flask('test') + cls.app.config.update({ + 'PLAINTEXT_SERVICE_HOST': 'localhost', + 'PLAINTEXT_SERVICE_PORT': '8889', + 'PLAINTEXT_PORT_8889_PROTO': 'http', + 'PLAINTEXT_VERIFY': False, + 'PLAINTEXT_ENDPOINT': 'http://localhost:8889', + 'JWT_SECRET': 'foosecret' + }) + cls.token = generate_token(cls.app, ['fulltext:create', 'fulltext:read']) + plaintext.PlainTextService.init_app(cls.app) + + @classmethod + def tearDownClass(cls): + """Tear down the plain text service.""" + cls.plaintext_api.kill() + cls.plaintext_api.remove() + cls.plaintext_worker.kill() + cls.plaintext_worker.remove() + cls.redis.kill() + cls.redis.remove() + cls.data.remove() + cls.network.remove() + + def test_get_status(self): + """Get the status endpoint.""" + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertEqual(pt.get_status(), + {'extractor': True, 'storage': True}) + + def test_is_available(self): + """Poll for availability.""" + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertTrue(pt.is_available()) + + def test_extraction(self): + """Request, poll, and retrieve a plain text extraction.""" + with self.app.app_context(): + pt = plaintext.PlainTextService.current_session() + self.assertIsNone( + pt.request_extraction('1234', 'foochex', self.token) + ) + tries = 0 + while not pt.extraction_is_complete('1234', 'foochex', self.token): + print('waiting for extraction to complete:', tries) + tries += 1 + time.sleep(5) + if tries > 20: + self.fail('waited too long') + print('done') + content = pt.retrieve_content('1234', 'foochex', self.token) + self.assertEqual(content.read().strip(), b'Hello World') + + +# From https://brendanzagaeski.appspot.com/0004.html +MINIMAL_PDF = """ +%PDF-1.1 +%¥±ë + +1 0 obj + << /Type /Catalog + /Pages 2 0 R + >> +endobj + +2 0 obj + << /Type /Pages + /Kids [3 0 R] + /Count 1 + /MediaBox [0 0 300 144] + >> +endobj + +3 0 obj + << /Type /Page + /Parent 2 0 R + /Resources + << /Font + << /F1 + << /Type /Font + /Subtype /Type1 + /BaseFont /Times-Roman + >> + >> + >> + /Contents 4 0 R + >> +endobj + +4 0 obj + << /Length 55 >> +stream + BT + /F1 18 Tf + 0 0 Td + (Hello World) Tj + ET +endstream +endobj + +xref +0 5 +0000000000 65535 f +0000000018 00000 n +0000000077 00000 n +0000000178 00000 n +0000000457 00000 n +trailer + << /Root 1 0 R + /Size 5 + >> +startxref +565 +%%EOF +""" \ No newline at end of file diff --git a/src/arxiv/submission/services/preview/__init__.py b/src/arxiv/submission/services/preview/__init__.py new file mode 100644 index 0000000..4a5a635 --- /dev/null +++ b/src/arxiv/submission/services/preview/__init__.py @@ -0,0 +1,3 @@ +"""Integration with the submission preview service.""" + +from .preview import PreviewService \ No newline at end of file diff --git a/src/arxiv/submission/services/preview/preview.py b/src/arxiv/submission/services/preview/preview.py new file mode 100644 index 0000000..0a9789d --- /dev/null +++ b/src/arxiv/submission/services/preview/preview.py @@ -0,0 +1,218 @@ +"""Integration with the submission preview service.""" + +import io +from datetime import datetime +from http import HTTPStatus as status +from typing import Tuple, Any, IO, Callable, Iterator, Optional, Literal +from urllib3.util.retry import Retry + +from backports.datetime_fromisoformat import MonkeyPatch +from mypy_extensions import TypedDict + +from arxiv.base import logging +from arxiv.integration.api import service, exceptions + +from ...domain.preview import Preview +from ..util import ReadWrapper + + +MonkeyPatch.patch_fromisoformat() +logger = logging.getLogger(__name__) + + +class AlreadyExists(exceptions.BadRequest): + """An attempt was made to deposit a preview that already exists.""" + + +class PreviewMeta(TypedDict): + added: str + size_bytes: int + checksum: str + + +class PreviewService(service.HTTPIntegration): + """Represents an interface to the submission preview.""" + + VERSION = '17057e6' + SERVICE = 'preview' + + class Meta: + """Configuration for :class:`PreviewService` integration.""" + + service_name = 'preview' + + def get_retry_config(self) -> Retry: + """ + Configure to only retry on connection errors. + + We are likely to be sending non-seakable streams, so retry should be + handled at the application level. + """ + return Retry( + total=10, + read=0, + connect=10, + status=0, + backoff_factor=0.5 + ) + + def is_available(self, **kwargs: Any) -> bool: + """Check our connection to the filesystem service.""" + timeout: float = kwargs.get('timeout', 0.2) + try: + response = self.request('head', '/status', timeout=timeout) + except Exception as e: + logger.error('Encountered error calling filesystem: %s', e) + return False + return bool(response.status_code == status.OK) + + def get(self, source_id: int, checksum: str, token: str) \ + -> Tuple[IO[bytes], str]: + """ + Retrieve the content of the PDF preview for a submission. + + Parameters + ---------- + source_id : int + Unique identifier of the source package from which the preview was + generated. + checksum : str + URL-safe base64-encoded MD5 hash of the source package content. + token : str + Authnz token for the request. + + Returns + ------- + :class:`io.BytesIO` + Streaming content of the preview. + str + URL-safe base64-encoded MD5 hash of the preview content. + + """ + response = self.request('get', f'/{source_id}/{checksum}/content', + token) + preview_checksum = str(response.headers['ETag']) + stream = ReadWrapper(response.iter_content, + int(response.headers['Content-Length'])) + return stream, preview_checksum + + def get_metadata(self, source_id: int, checksum: str, token: str) \ + -> Preview: + """ + Retrieve metadata about a preview. + + Parameters + ---------- + source_id : int + Unique identifier of the source package from which the preview was + generated. + checksum : str + URL-safe base64-encoded MD5 hash of the source package content. + token : str + Authnz token for the request. + + Returns + ------- + :class:`.Preview` + + """ + response = self.request('get', f'/{source_id}/{checksum}', token) + response_data: PreviewMeta = response.json() + # fromisoformat() is backported from 3.7. + added: datetime = datetime.fromisoformat(response_data['added']) # type: ignore + return Preview(source_id=source_id, + source_checksum=checksum, + preview_checksum=response_data['checksum'], + added=added, + size_bytes=response_data['size_bytes']) + + def deposit(self, source_id: int, checksum: str, stream: IO[bytes], + token: str, overwrite: bool = False, + content_checksum: Optional[str] = None) -> Preview: + """ + Deposit a preview. + + Parameters + ---------- + source_id : int + Unique identifier of the source package from which the preview was + generated. + checksum : str + URL-safe base64-encoded MD5 hash of the source package content. + stream : :class:`.io.BytesIO` + Streaming content of the preview. + token : str + Authnz token for the request. + overwrite : bool + If ``True``, any existing preview will be overwritten. + + Returns + ------- + :class:`.Preview` + + Raises + ------ + :class:`AlreadyExists` + Raised when ``overwrite`` is ``False`` and a preview already exists + for the provided ``source_id`` and ``checksum``. + + """ + headers = {'Overwrite': 'true' if overwrite else 'false'} + if content_checksum is not None: + headers['ETag'] = content_checksum + + # print('here is what we are about to put to the preview service') + # raw_content = stream.read() + # print('data length:: ', len(raw_content)) + + try: + response = self.request('put', f'/{source_id}/{checksum}/content', + token, data=stream, #io.BytesIO(raw_content), #stream + headers=headers, + expected_code=[status.CREATED], + allow_2xx_redirects=False) + except exceptions.BadRequest as e: + if e.response.status_code == status.CONFLICT: + raise AlreadyExists('Preview already exists', e.response) from e + raise + response_data: PreviewMeta = response.json() + # fromisoformat() is backported from 3.7. + added: datetime = datetime.fromisoformat(response_data['added']) # type: ignore + return Preview(source_id=source_id, + source_checksum=checksum, + preview_checksum=response_data['checksum'], + added=added, + size_bytes=response_data['size_bytes']) + + def has_preview(self, source_id: int, checksum: str, token: str, + content_checksum: Optional[str] = None) -> bool: + """ + Check whether a preview exists for a specific source package. + + Parameters + ---------- + source_id : int + Unique identifier of the source package from which the preview was + generated. + checksum : str + URL-safe base64-encoded MD5 hash of the source package content. + token : str + Authnz token for the request. + content_checksum : str or None + URL-safe base64-encoded MD5 hash of the preview content. If + provided, will return ``True`` only if this value matches the + value of the ``ETag`` header returned by the preview service. + + Returns + ------- + bool + + """ + try: + response = self.request('head', f'/{source_id}/{checksum}', token) + except exceptions.NotFound: + return False + if content_checksum is not None: + if response.headers.get('ETag') != content_checksum: + return False + return True diff --git a/src/arxiv/submission/services/preview/tests.py b/src/arxiv/submission/services/preview/tests.py new file mode 100644 index 0000000..34414c3 --- /dev/null +++ b/src/arxiv/submission/services/preview/tests.py @@ -0,0 +1,142 @@ +"""Integration tests for the preview service.""" + +import io +import os +import time +from unittest import TestCase +from flask import Flask +import docker + +from .preview import PreviewService, AlreadyExists, exceptions + + +class TestPreviewIntegration(TestCase): + """Integration tests for the preview service module.""" + + __test__ = bool(int(os.environ.get('WITH_INTEGRATION', '0'))) + + @classmethod + def setUpClass(cls): + """Start up the preview service, backed by localstack S3.""" + client = docker.from_env() + image = f'arxiv/{PreviewService.SERVICE}' + client.images.pull(image, tag=PreviewService.VERSION) + cls.network = client.networks.create('test-preview-network') + cls.localstack = client.containers.run( + 'atlassianlabs/localstack', + detach=True, + ports={'4572/tcp': 5572}, + network='test-preview-network', + name='localstack', + environment={'USE_SSL': 'true'} + ) + cls.container = client.containers.run( + f'{image}:{PreviewService.VERSION}', + detach=True, + network='test-preview-network', + ports={'8000/tcp': 8889}, + environment={'S3_ENDPOINT': 'https://localstack:4572', + 'S3_VERIFY': '0', + 'NAMESPACE': 'test'} + ) + time.sleep(5) + + cls.app = Flask('test') + cls.app.config.update({ + 'PREVIEW_SERVICE_HOST': 'localhost', + 'PREVIEW_SERVICE_PORT': '8889', + 'PREVIEW_PORT_8889_PROTO': 'http', + 'PREVIEW_VERIFY': False, + 'PREVIEW_ENDPOINT': 'http://localhost:8889' + + }) + PreviewService.init_app(cls.app) + + @classmethod + def tearDownClass(cls): + """Tear down the preview service and localstack.""" + cls.container.kill() + cls.container.remove() + cls.localstack.kill() + cls.localstack.remove() + cls.network.remove() + + def test_get_status(self): + """Get the status endpoint.""" + with self.app.app_context(): + pv = PreviewService.current_session() + self.assertEqual(pv.get_status(), {'iam': 'ok'}) + + def test_is_available(self): + """Poll for availability.""" + with self.app.app_context(): + pv = PreviewService.current_session() + self.assertTrue(pv.is_available()) + + def test_deposit_retrieve(self): + """Deposit and retrieve a preview.""" + with self.app.app_context(): + pv = PreviewService.current_session() + content = io.BytesIO(b'foocontent') + source_id = 1234 + checksum = 'foochex==' + token = 'footoken' + preview = pv.deposit(source_id, checksum, content, token) + self.assertEqual(preview.source_id, 1234) + self.assertEqual(preview.source_checksum, 'foochex==') + self.assertEqual(preview.preview_checksum, + 'ewrggAHdCT55M1uUfwKLEA==') + self.assertEqual(preview.size_bytes, 10) + + stream, preview_checksum = pv.get(source_id, checksum, token) + self.assertEqual(stream.read(), b'foocontent') + self.assertEqual(preview_checksum, preview.preview_checksum) + + def test_deposit_conflict(self): + """Deposit the same preview twice.""" + with self.app.app_context(): + pv = PreviewService.current_session() + content = io.BytesIO(b'foocontent') + source_id = 1235 + checksum = 'foochex==' + token = 'footoken' + preview = pv.deposit(source_id, checksum, content, token) + self.assertEqual(preview.source_id, 1235) + self.assertEqual(preview.source_checksum, 'foochex==') + self.assertEqual(preview.preview_checksum, + 'ewrggAHdCT55M1uUfwKLEA==') + self.assertEqual(preview.size_bytes, 10) + + with self.assertRaises(AlreadyExists): + pv.deposit(source_id, checksum, content, token) + + def test_deposit_conflict_force(self): + """Deposit the same preview twice and explicitly overwrite.""" + with self.app.app_context(): + pv = PreviewService.current_session() + content = io.BytesIO(b'foocontent') + source_id = 1236 + checksum = 'foochex==' + token = 'footoken' + preview = pv.deposit(source_id, checksum, content, token) + self.assertEqual(preview.source_id, 1236) + self.assertEqual(preview.source_checksum, 'foochex==') + self.assertEqual(preview.preview_checksum, + 'ewrggAHdCT55M1uUfwKLEA==') + self.assertEqual(preview.size_bytes, 10) + + content = io.BytesIO(b'barcontent') + preview = pv.deposit(source_id, checksum, content, token, + overwrite=True) + self.assertEqual(preview.source_id, 1236) + self.assertEqual(preview.source_checksum, 'foochex==') + self.assertEqual(preview.preview_checksum, + 'uW94u_u4xfDA3lcVd354ng==') + self.assertEqual(preview.size_bytes, 10) + + def get_nonexistant_preview(self): + """Try to get a non-existant preview.""" + with self.app.app_context(): + pv = PreviewService.current_session() + with self.assertRaises(exceptions.NotFound): + pv.get(9876, 'foochex==', 'footoken') \ No newline at end of file diff --git a/src/arxiv/submission/services/stream/__init__.py b/src/arxiv/submission/services/stream/__init__.py new file mode 100644 index 0000000..f7fcf5b --- /dev/null +++ b/src/arxiv/submission/services/stream/__init__.py @@ -0,0 +1,3 @@ +"""Emits events to the submission stream.""" + +from .stream import StreamPublisher diff --git a/src/arxiv/submission/services/stream/stream.py b/src/arxiv/submission/services/stream/stream.py new file mode 100644 index 0000000..56ed6e8 --- /dev/null +++ b/src/arxiv/submission/services/stream/stream.py @@ -0,0 +1,128 @@ +"""Provides the stream publishing integration.""" + +from typing import Optional, Any + +import boto3 +from botocore.exceptions import ClientError +from retry import retry + +from arxiv.base import logging +from arxiv.base.globals import get_application_config, get_application_global +from arxiv.integration.meta import MetaIntegration + +from ...domain import Submission, Event +from ...serializer import dumps + +logger = logging.getLogger(__name__) + + +class StreamPublisher(metaclass=MetaIntegration): + def __init__(self, stream: str, partition_key: str, + aws_access_key_id: str, aws_secret_access_key: str, + region_name: str, endpoint_url: Optional[str] = None, + verify: bool = True) -> None: + self.stream = stream + self.partition_key = partition_key + self.client = boto3.client('kinesis', + region_name=region_name, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + verify=verify) + + @classmethod + def init_app(cls, app: object = None) -> None: + """Set default configuration params for an application instance.""" + config = get_application_config(app) + config.setdefault('AWS_ACCESS_KEY_ID', '') + config.setdefault('AWS_SECRET_ACCESS_KEY', '') + config.setdefault('AWS_REGION', 'us-east-1') + config.setdefault('KINESIS_ENDPOINT', None) + config.setdefault('KINESIS_VERIFY', True) + config.setdefault('KINESIS_STREAM', 'SubmissionEvents') + config.setdefault('KINESIS_PARTITION_KEY', '0') + + @classmethod + def get_session(cls, app: object = None) -> 'StreamPublisher': + """Get a new session with the stream.""" + config = get_application_config(app) + aws_access_key_id = config['AWS_ACCESS_KEY_ID'] + aws_secret_access_key = config['AWS_SECRET_ACCESS_KEY'] + aws_region = config['AWS_REGION'] + kinesis_endpoint = config['KINESIS_ENDPOINT'] + kinesis_verify = config['KINESIS_VERIFY'] + kinesis_stream = config['KINESIS_STREAM'] + partition_key = config['KINESIS_PARTITION_KEY'] + return cls(kinesis_stream, partition_key, aws_access_key_id, + aws_secret_access_key, aws_region, kinesis_endpoint, + kinesis_verify) + + @classmethod + def current_session(cls) -> 'StreamPublisher': + """Get/create :class:`.StreamPublisher` for this context.""" + g = get_application_global() + if not g: + return cls.get_session() + elif 'stream' not in g: + g.stream = cls.get_session() # type: ignore + return g.stream # type: ignore + + def is_available(self, **kwargs: Any) -> bool: + """Test our ability to put records.""" + data = bytes(dumps({}), encoding='utf-8') + try: + self.client.put_record(StreamName=self.stream, Data=data, + PartitionKey=self.partition_key) + except Exception as e: + logger.error('Encountered error while putting to stream: %s', e) + return False + return True + + def _create_stream(self) -> None: + try: + self.client.create_stream(StreamName=self.stream, ShardCount=1) + except self.client.exceptions.ResourceInUseException: + logger.info('Stream %s already exists', self.stream) + return + + def _wait_for_stream(self, retries: int = 0, delay: int = 0) -> None: + waiter = self.client.get_waiter('stream_exists') + waiter.wait( + StreamName=self.stream, + WaiterConfig={ + 'Delay': delay, + 'MaxAttempts': retries + } + ) + + @retry(RuntimeError, tries=5, delay=2, backoff=2) + def initialize(self) -> None: + """Perform initial checks, e.g. at application start-up.""" + logger.info('initialize Kinesis stream') + data = bytes(dumps({}), encoding='utf-8') + try: + self.client.put_record(StreamName=self.stream, Data=data, + PartitionKey=self.partition_key) + logger.info('storage service is already available') + except ClientError as exc: + if exc.response['Error']['Code'] == 'ResourceNotFoundException': + logger.info('stream does not exist; creating') + self._create_stream() + logger.info('wait for stream to be available') + self._wait_for_stream(retries=10, delay=5) + raise RuntimeError('Failed to initialize stream') from exc + except self.client.exceptions.ResourceNotFoundException: + logger.info('stream does not exist; creating') + self._create_stream() + logger.info('wait for stream to be available') + self._wait_for_stream(retries=10, delay=5) + except Exception as exc: + raise RuntimeError('Failed to initialize stream') from exc + return + + def put(self, event: Event, before: Submission, after: Submission) -> None: + """Put an :class:`.Event` on the stream.""" + payload = {'event': event, 'before': before, 'after': after} + data = bytes(dumps(payload), encoding='utf-8') + self.client.put_record(StreamName=self.stream, Data=data, + PartitionKey=self.partition_key) diff --git a/src/arxiv/submission/services/util.py b/src/arxiv/submission/services/util.py new file mode 100644 index 0000000..eb277af --- /dev/null +++ b/src/arxiv/submission/services/util.py @@ -0,0 +1,47 @@ +"""Helpers for service modules.""" + +import io +from typing import Callable, Iterator, Any, Optional, Literal + + + +class ReadWrapper(io.BytesIO): + """Wraps a response body streaming iterator to provide ``read()``.""" + + def __init__(self, iter_content: Callable[[int], Iterator[bytes]], + content_size_bytes: int, size: int = 4096) -> None: + """Initialize the streaming iterator.""" + self._iter_content = iter_content(size) + # Must be set for requests to treat this as streamable "file like + # object". + # See https://github.com/psf/requests/blob/bedd9284c9646e50c10b3defdf519d4ba479e2c7/requests/models.py#L476 + self.len = content_size_bytes + + def seekable(self) -> Literal[False]: + """Indicate that this is a non-seekable stream.""" + return False + + def readable(self) -> Literal[True]: + """Indicate that it *is* a readable stream.""" + return True + + def read(self, *args: Any, **kwargs: Any) -> bytes: + """ + Read the next chunk of the content stream. + + Arguments are ignored, since the chunk size must be set at the start. + """ + # print('read with size', size) + # if size == -1: + # return b''.join(self._iter_content) + return next(self._iter_content) + + def __len__(self) -> int: + return self.len + + # This must be included for requests to treat this as a streamble + # "file-like object". + # See https://github.com/psf/requests/blob/bedd9284c9646e50c10b3defdf519d4ba479e2c7/requests/models.py#L470-L473 + def __iter__(self) -> Iterator[bytes]: + """Generate chunks of body content.""" + return self._iter_content diff --git a/src/arxiv/submission/templates/submission-core/confirmation-email.html b/src/arxiv/submission/templates/submission-core/confirmation-email.html new file mode 100644 index 0000000..364ad7e --- /dev/null +++ b/src/arxiv/submission/templates/submission-core/confirmation-email.html @@ -0,0 +1,28 @@ +{% extends "mail/base.html" %} + +{% block email_title %}arXiv submission submit/{{ submission_id }}{% endblock email_title %} + +{% block message_title %}We have received your submission to arXiv, titled "{{ submission.metadata.title }}"{% endblock message_title %} + +{% block message_body %} +

+ Your temporary submission identifier is: submit/{{ submission_id }}. + To preview your submission, check the + submission status page. +

+ +

+ Your article is scheduled to be announced at {{ announce_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }}. The abstract + will appear in the subsequent mailing as displayed below, except that the + submission identifier will be replaced by the official arXiv identifier. + Updates before {{ freeze_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }} will not delay announcement. +

+ +

+ A paper password will be emailed to you when the article is announced. You + should share this with co-authors to allow them to claim ownership. If you + have a problem that you are not able to resolve through the web interface, + contact {{ config.SUPPORT_EMAIL }} with a + description of the issue and reference the submission identifier. +

+{% endblock message_body %} diff --git a/src/arxiv/submission/templates/submission-core/confirmation-email.txt b/src/arxiv/submission/templates/submission-core/confirmation-email.txt new file mode 100644 index 0000000..9db073c --- /dev/null +++ b/src/arxiv/submission/templates/submission-core/confirmation-email.txt @@ -0,0 +1,38 @@ +{% import "base/macros.html" as macros %} + +We have received your submission to arXiv. + +Your temporary submission identifier is: submit/{{ submission_id }}. You may +update your submission at: {{ url_for("submission", +submission_id=submission_id) }}. + +Your article is scheduled to be announced at {{ announce_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }}. The +abstract will appear in the subsequent mailing as displayed below, except that +the submission identifier will be replaced by the official arXiv identifier. +Updates before {{ freeze_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }} will not delay announcement. + +A paper password will be emailed to you when the article is announced. You +should share this with co-authors to allow them to claim ownership. If you have +a problem that you are not able to resolve through the web interface, contact +{{ config.SUPPORT_EMAIL }} with a description of the issue and reference the +submission identifier. + +{{ macros.abs_plaintext( + arxiv_id, + submission.metadata.title, + submission.metadata.authors_display, + submission.metadata.abstract, + submission.created, + submission.primary_classification.category, + submission.creator.name, + submission.creator.email, + submission.source_content.uncompressed_size, + submission.license.uri, + comments = submission.metadata.comments, + msc_class = submission.metadata.msc_class, + acm_class = submission.metadata.acm_class, + journal_ref = submission.metadata.journal_ref, + report_num = submission.metadata.report_num, + version = submission.version, + submission_history = [], + secondary_categories = submission.secondary_categories) }} diff --git a/src/arxiv/submission/tests/#util.py# b/src/arxiv/submission/tests/#util.py# new file mode 100644 index 0000000..15868ba --- /dev/null +++ b/src/arxiv/submission/tests/#util.py# @@ -0,0 +1,74 @@ +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta +from typing import Optional, List + +from flask import Flask +from pytz import UTC + +from arxiv.users import domain, auth + +from ..services import classic + + +@contextmanager +def in_memory_db(app: Optional[Flask] = None): + """Provide an in-memory sqlite database for testing purposes.""" + if app is None: + app = Flask('foo') + app.config['CLASSIC_DATABASE_URI'] = 'sqlite://' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with app.app_context(): + classic.init_app(app) + classic.create_all() + try: + yield classic.current_session() + except Exception: + raise + finally: + classic.drop_all() + + +# Generate authentication token +def generate_token(app: Flask, scope: List[str]) -> str: + """Helper function for generating a JWT.""" + secret = app.config.get('JWT_SECRET') + start = datetime.now(tz=UTC) + end = start + timedelta(seconds=36000) # Make this as long as you want. + user_id = '1' + email = 'foo@bar.com' + username = 'theuser' + first_name = 'Jane' + last_name = 'Doe' + suffix_name = 'IV' + affiliation = 'Cornell University' + rank = 3 + country = 'us' + default_category = 'astro-ph.GA' + submission_groups = 'grp_physics' + endorsements = 'astro-ph.CO,astro-ph.GA' + session = domain.Session( + session_id=str(uuid.uuid4()), + start_time=start, end_time=end, + user=domain.User( + user_id=user_id, + email=email, + username=username, + name=domain.UserFullName(first_name, last_name, suffix_name), + profile=domain.UserProfile( + affiliation=affiliation, + rank=int(rank), + country=country, + default_category=domain.Category(default_category), + submission_groups=submission_groups.split(',') + ) + ), + authorizations=domain.Authorizations( + scopes=scope, + endorsements=[domain.Category(cat.split('.', 1)) + for cat in endorsements.split(',')] + ) + ) + token = auth.tokens.encode(session, secret) + return token \ No newline at end of file diff --git a/src/arxiv/submission/tests/__init__.py b/src/arxiv/submission/tests/__init__.py new file mode 100644 index 0000000..53d76ad --- /dev/null +++ b/src/arxiv/submission/tests/__init__.py @@ -0,0 +1 @@ +"""Package-level tests for :mod:`events`.""" diff --git a/src/arxiv/submission/tests/annotations/__init__.py b/src/arxiv/submission/tests/annotations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/tests/api/__init__.py b/src/arxiv/submission/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/tests/api/test_api.py b/src/arxiv/submission/tests/api/test_api.py new file mode 100644 index 0000000..563d32c --- /dev/null +++ b/src/arxiv/submission/tests/api/test_api.py @@ -0,0 +1,183 @@ +"""Tests for :mod:`events` public API.""" + +from unittest import TestCase, mock +import os +from collections import defaultdict +from datetime import datetime, timedelta +from flask import Flask +from pytz import UTC +from ... import save, load, core, Submission, User, Event, \ + SubmissionMetadata +from ...domain.event import CreateSubmission, SetAuthors, Author, \ + SetTitle, SetAbstract +from ...exceptions import NoSuchSubmission, InvalidEvent +from ...services import classic + + +def mock_store_event(event, before, after, emit): + event.submission_id = 1 + after.submission_id = 1 + event.committed = True + emit(event) + return event, after + + +class TestLoad(TestCase): + """Test :func:`.load`.""" + + @mock.patch('submission.core.classic') + def test_load_existant_submission(self, mock_classic): + """When the submission exists, submission and events are returned.""" + u = User(12345, 'joe@joe.joe') + mock_classic.get_submission.return_value = ( + Submission(creator=u, submission_id=1, owner=u, + created=datetime.now(UTC)), + [CreateSubmission(creator=u, submission_id=1, committed=True)] + ) + submission, events = load(1) + self.assertEqual(mock_classic.get_submission.call_count, 1) + self.assertIsInstance(submission, Submission, + "A submission should be returned") + self.assertIsInstance(events, list, + "A list of events should be returned") + self.assertIsInstance(events[0], Event, + "A list of events should be returned") + + @mock.patch('submission.core.classic') + def test_load_nonexistant_submission(self, mock_classic): + """When the submission does not exist, an exception is raised.""" + mock_classic.get_submission.side_effect = classic.NoSuchSubmission + mock_classic.NoSuchSubmission = classic.NoSuchSubmission + with self.assertRaises(NoSuchSubmission): + load(1) + + +class TestSave(TestCase): + """Test :func:`.save`.""" + + @mock.patch(f'{core.__name__}.StreamPublisher') + @mock.patch('submission.core.classic') + def test_save_creation_event(self, mock_database, mock_publisher): + """A :class:`.CreationEvent` is passed.""" + mock_database.store_event = mock_store_event + mock_database.exceptions = classic.exceptions + user = User(12345, 'joe@joe.joe') + event = CreateSubmission(creator=user) + submission, events = save(event) + self.assertIsInstance(submission, Submission, + "A submission instance should be returned") + self.assertIsInstance(events[0], Event, + "Should return a list of events") + self.assertEqual(events[0], event, + "The first event should be the event that was passed") + self.assertIsNotNone(submission.submission_id, + "Submission ID should be set.") + + self.assertEqual(mock_publisher.put.call_count, 1) + args = event, None, submission + self.assertTrue(mock_publisher.put.called_with(*args)) + + @mock.patch(f'{core.__name__}.StreamPublisher') + @mock.patch('submission.core.classic') + def test_save_events_from_scratch(self, mock_database, mock_publisher): + """Save multiple events for a nonexistant submission.""" + mock_database.store_event = mock_store_event + mock_database.exceptions = classic.exceptions + user = User(12345, 'joe@joe.joe') + e = CreateSubmission(creator=user) + e2 = SetTitle(creator=user, title='footitle') + submission, events = save(e, e2) + + self.assertEqual(submission.metadata.title, 'footitle') + self.assertIsInstance(submission.submission_id, int) + self.assertEqual(submission.created, e.created) + + self.assertEqual(mock_publisher.put.call_count, 2) + self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) + self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) + + @mock.patch(f'{core.__name__}.StreamPublisher') + @mock.patch('submission.core.classic') + def test_create_and_update_authors(self, mock_database, mock_publisher): + """Save multiple events for a nonexistant submission.""" + mock_database.store_event = mock_store_event + mock_database.exceptions = classic.exceptions + user = User(12345, 'joe@joe.joe') + e = CreateSubmission(creator=user) + e2 = SetAuthors(creator=user, authors=[ + Author(0, forename='Joe', surname="Bloggs", email="joe@blog.gs") + ]) + submission, events = save(e, e2) + self.assertIsInstance(submission.metadata.authors[0], Author) + + self.assertEqual(mock_publisher.put.call_count, 2) + self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) + self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + @mock.patch('submission.core.classic') + def test_save_from_scratch_without_creation_event(self, mock_database): + """An exception is raised when there is no creation event.""" + mock_database.store_event = mock_store_event + user = User(12345, 'joe@joe.joe') + e2 = SetTitle(creator=user, title='foo') + with self.assertRaises(NoSuchSubmission): + save(e2) + + @mock.patch(f'{core.__name__}.StreamPublisher') + @mock.patch('submission.core.classic') + def test_save_events_on_existing_submission(self, mock_db, mock_publisher): + """Save multiple sets of events in separate calls to :func:`.save`.""" + mock_db.exceptions = classic.exceptions + cache = {} + + def mock_store_event_with_cache(event, before, after, emit): + if after.submission_id is None: + if before is not None: + before.submission_id = 1 + after.submission_id = 1 + + event.committed = True + event.submission_id = after.submission_id + if event.submission_id not in cache: + cache[event.submission_id] = (None, []) + cache[event.submission_id] = ( + after, cache[event.submission_id][1] + [event] + ) + emit(event) + return event, after + + def mock_get_events(submission_id, *args, **kwargs): + return cache[submission_id] + + mock_db.store_event = mock_store_event_with_cache + mock_db.get_submission = mock_get_events + + # Here is the first set of events. + user = User(12345, 'joe@joe.joe') + e = CreateSubmission(creator=user) + e2 = SetTitle(creator=user, title='footitle') + submission, _ = save(e, e2) + submission_id = submission.submission_id + + # Now we apply a second set of events. + e3 = SetAbstract(creator=user, abstract='bar'*10) + submission2, _ = save(e3, submission_id=submission_id) + + # The submission state reflects all three events. + self.assertEqual(submission2.metadata.abstract, 'bar'*10, + "State of the submission should reflect both sets" + " of events.") + self.assertEqual(submission2.metadata.title, 'footitle', + "State of the submission should reflect both sets" + " of events.") + self.assertEqual(submission2.created, e.created, + "The creation date of the submission should be the" + " original creation date.") + self.assertEqual(submission2.submission_id, submission_id, + "The submission ID should remain the same.") + + self.assertEqual(mock_publisher.put.call_count, 3) + self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) + self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) + self.assertEqual(mock_publisher.put.mock_calls[2][1][0], e3) diff --git a/src/arxiv/submission/tests/classic/__init__.py b/src/arxiv/submission/tests/classic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/tests/classic/test_classic_integration.py b/src/arxiv/submission/tests/classic/test_classic_integration.py new file mode 100644 index 0000000..f8bb92e --- /dev/null +++ b/src/arxiv/submission/tests/classic/test_classic_integration.py @@ -0,0 +1,1064 @@ +""" +Tests for integration with the classic system. + +Provides test cases for the new events model's ability to replicate the classic +model. The function `TestClassicUIWorkflow.test_classic_workflow()` provides +keyword arguments to pass different types of data through the workflow. + +TODO: Presently, `test_classic_workflow` expects `core.domain` objects. That +should change to instantiate each object at runtime for database imports. +""" + +from unittest import TestCase, mock +from datetime import datetime +import tempfile +from pytz import UTC +from flask import Flask + +from arxiv.base import Base +from arxiv import mail +from ..util import in_memory_db +from ... import domain +from ...domain.event import * +from ... import * +from ...services import classic + + +# class TestClassicUIWorkflow(TestCase): +# """Replicate the classic submission UI workflow.""" + +# def setUp(self): +# """An arXiv user is submitting a new paper.""" +# self.app = Flask(__name__) +# self.app.config['EMAIL_ENABLED'] = False +# self.app.config['WAIT_FOR_SERVICES'] = False +# Base(self.app) +# init_app(self.app) +# mail.init_app(self.app) +# self.submitter = domain.User(1234, email='j.user@somewhere.edu', +# forename='Jane', surname='User', +# endorsements=['cs.DL', 'cs.IR']) +# self.unicode_submitter = domain.User(12345, +# email='j.user@somewhere.edu', +# forename='大', surname='用户', +# endorsements=['cs.DL', 'cs.IR']) + +# @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) +# def test_classic_workflow(self, submitter=None, metadata=None, +# authors=None): +# """Submitter proceeds through workflow in a linear fashion.""" + +# # Instantiate objects that have not yet been instantiated or use defaults. +# if submitter is None: +# submitter = self.submitter +# if metadata is None: +# metadata = [ +# ('title', 'Foo title'), +# ('abstract', "One morning, as Gregor Samsa was waking up..."), +# ('comments', '5 pages, 2 turtle doves'), +# ('report_num', 'asdf1234'), +# ('doi', '10.01234/56789'), +# ('journal_ref', 'Foo Rev 1, 2 (1903)') +# ] +# metadata = dict(metadata) + + +# # TODO: Process data in dictionary form to Author objects. +# if authors is None: +# authors = [Author(order=0, +# forename='Bob', +# surname='Paulson', +# email='Robert.Paulson@nowhere.edu', +# affiliation='Fight Club' +# )] + +# with in_memory_db(self.app) as session: +# # Submitter clicks on 'Start new submission' in the user dashboard. +# submission, stack = save( +# CreateSubmission(creator=submitter) +# ) +# self.assertIsNotNone(submission.submission_id, +# "A submission ID is assigned") +# self.assertEqual(len(stack), 1, "A single command is executed.") + +# db_submission = session.query(classic.models.Submission)\ +# .get(submission.submission_id) +# self.assertEqual(db_submission.submission_id, +# submission.submission_id, +# "A row is added to the submission table") +# self.assertEqual(db_submission.submitter_id, +# submitter.native_id, +# "Submitter ID set on submission") +# self.assertEqual(db_submission.submitter_email, +# submitter.email, +# "Submitter email set on submission") +# self.assertEqual(db_submission.submitter_name, submitter.name, +# "Submitter name set on submission") +# self.assertEqual(db_submission.created.replace(tzinfo=UTC), +# submission.created, +# "Creation datetime set correctly") + +# # TODO: What else to check here? + +# # /start: Submitter completes the start submission page. +# license_uri = 'http://creativecommons.org/publicdomain/zero/1.0/' +# submission, stack = save( +# ConfirmContactInformation(creator=submitter), +# ConfirmAuthorship( +# creator=submitter, +# submitter_is_author=True +# ), +# SetLicense( +# creator=submitter, +# license_uri=license_uri, +# license_name='CC0 1.0' +# ), +# ConfirmPolicy(creator=submitter), +# SetPrimaryClassification( +# creator=submitter, +# category='cs.DL' +# ), +# submission_id=submission.submission_id +# ) + +# self.assertEqual(len(stack), 6, +# "Six commands have been executed in total.") + +# db_submission = session.query(classic.models.Submission)\ +# .get(submission.submission_id) +# self.assertEqual(db_submission.userinfo, 1, +# "Contact verification set correctly in database.") +# self.assertEqual(db_submission.is_author, 1, +# "Authorship status set correctly in database.") +# self.assertEqual(db_submission.license, license_uri, +# "License set correctly in database.") +# self.assertEqual(db_submission.agree_policy, 1, +# "Policy acceptance set correctly in database.") +# self.assertEqual(len(db_submission.categories), 1, +# "A single category is associated in the database") +# self.assertEqual(db_submission.categories[0].is_primary, 1, +# "Primary category is set correct in the database") +# self.assertEqual(db_submission.categories[0].category, 'cs.DL', +# "Primary category is set correct in the database") + +# # /addfiles: Submitter has uploaded files to the file management +# # service, and verified that they compile. Now they associate the +# # content package with the submission. +# submission, stack = save( +# SetUploadPackage( +# creator=submitter, +# checksum="a9s9k342900skks03330029k", +# source_format=domain.submission.SubmissionContent.Format('tex'), +# identifier=123, +# uncompressed_size=593992, +# compressed_size=593992 +# ), +# submission_id=submission.submission_id +# ) + +# self.assertEqual(len(stack), 7, +# "Seven commands have been executed in total.") +# db_submission = session.query(classic.models.Submission)\ +# .get(submission.submission_id) +# self.assertEqual(db_submission.must_process, 1, +# "There is no compilation yet") +# self.assertEqual(db_submission.source_size, 593992, +# "Source package size set correctly in database") +# self.assertEqual(db_submission.source_format, 'tex', +# "Source format set correctly in database") + +# # /metadata: Submitter adds metadata to their submission, including +# # authors. In this package, we model authors in more detail than +# # in the classic system, but we should preserve the canonical +# # format in the db for legacy components' sake. +# submission, stack = save( +# SetTitle(creator=self.submitter, title=metadata['title']), +# SetAbstract(creator=self.submitter, +# abstract=metadata['abstract']), +# SetComments(creator=self.submitter, +# comments=metadata['comments']), +# SetJournalReference(creator=self.submitter, +# journal_ref=metadata['journal_ref']), +# SetDOI(creator=self.submitter, doi=metadata['doi']), +# SetReportNumber(creator=self.submitter, +# report_num=metadata['report_num']), +# SetAuthors(creator=submitter, authors=authors), +# submission_id=submission.submission_id +# ) +# db_submission = session.query(classic.models.Submission) \ +# .get(submission.submission_id) +# self.assertEqual(db_submission.title, dict(metadata)['title'], +# "Title updated as expected in database") +# self.assertEqual(db_submission.abstract, +# dict(metadata)['abstract'], +# "Abstract updated as expected in database") +# self.assertEqual(db_submission.comments, +# dict(metadata)['comments'], +# "Comments updated as expected in database") +# self.assertEqual(db_submission.report_num, +# dict(metadata)['report_num'], +# "Report number updated as expected in database") +# self.assertEqual(db_submission.doi, dict(metadata)['doi'], +# "DOI updated as expected in database") +# self.assertEqual(db_submission.journal_ref, +# dict(metadata)['journal_ref'], +# "Journal ref updated as expected in database") + +# author_str = ';'.join( +# [f"{author.forename} {author.surname} ({author.affiliation})" +# for author in authors] +# ) +# self.assertEqual(db_submission.authors, +# author_str, +# "Authors updated in canonical format in database") +# self.assertEqual(len(stack), 14, +# "Fourteen commands have been executed in total.") + +# # /preview: Submitter adds a secondary classification. +# submission, stack = save( +# AddSecondaryClassification( +# creator=submitter, +# category='cs.IR' +# ), +# submission_id=submission.submission_id +# ) +# db_submission = session.query(classic.models.Submission)\ +# .get(submission.submission_id) + +# self.assertEqual(len(db_submission.categories), 2, +# "A secondary category is added in the database") +# secondaries = [ +# db_cat for db_cat in db_submission.categories +# if db_cat.is_primary == 0 +# ] +# self.assertEqual(len(secondaries), 1, +# "A secondary category is added in the database") +# self.assertEqual(secondaries[0].category, 'cs.IR', +# "A secondary category is added in the database") +# self.assertEqual(len(stack), 15, +# "Fifteen commands have been executed in total.") + +# # /preview: Submitter finalizes submission. +# finalize = FinalizeSubmission(creator=submitter) +# submission, stack = save( +# finalize, submission_id=submission.submission_id +# ) +# db_submission = session.query(classic.models.Submission)\ +# .get(submission.submission_id) + +# self.assertEqual(db_submission.status, db_submission.SUBMITTED, +# "Submission status set correctly in database") +# self.assertEqual(db_submission.submit_time.replace(tzinfo=UTC), +# finalize.created, +# "Submit time is set.") +# self.assertEqual(len(stack), 16, +# "Sixteen commands have been executed in total.") + +# def test_unicode_submitter(self): +# """Submitter proceeds through workflow in a linear fashion.""" +# submitter = self.unicode_submitter +# metadata = [ +# ('title', '优秀的称号'), +# ('abstract', "当我有一天正在上学的时候当我有一天正在上学的时候"), +# ('comments', '5页2龟鸠'), +# ('report_num', 'asdf1234'), +# ('doi', '10.01234/56789'), +# ('journal_ref', 'Foo Rev 1, 2 (1903)') +# ] +# authors = [Author(order=0, forename='惊人', surname='用户', +# email='amazing.user@nowhere.edu', +# affiliation='Fight Club')] +# with self.app.app_context(): +# self.app.config['ENABLE_CALLBACKS'] = 0 +# self.test_classic_workflow(submitter=submitter, metadata=metadata, +# authors=authors) + +# def test_texism_titles(self): +# """Submitter proceeds through workflow in a linear fashion.""" +# metadata = [ +# ('title', 'Revisiting $E = mc^2$'), +# ('abstract', "$E = mc^2$ is a foundational concept in physics"), +# ('comments', '5 pages, 2 turtle doves'), +# ('report_num', 'asdf1234'), +# ('doi', '10.01234/56789'), +# ('journal_ref', 'Foo Rev 1, 2 (1903)') +# ] +# with self.app.app_context(): +# self.app.config['ENABLE_CALLBACKS'] = 1 +# self.test_classic_workflow(metadata=metadata) + + +class TestReplacementIntegration(TestCase): + """Test integration with the classic database with replacements.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + cls.app.config['WAIT_FOR_SERVICES'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """An arXiv user is submitting a new paper.""" + self.submitter = domain.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL']) + + # Create and finalize a new submission. + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + with self.app.app_context(): + classic.create_all() + metadata=dict([ + ('title', 'Foo title'), + ('abstract', "One morning, as Gregor Samsa was..."), + ('comments', '5 pages, 2 turtle doves'), + ('report_num', 'asdf1234'), + ('doi', '10.01234/56789'), + ('journal_ref', 'Foo Rev 1, 2 (1903)') + ]) + self.submission, _ = save( + CreateSubmission(creator=self.submitter), + ConfirmContactInformation(creator=self.submitter), + ConfirmAuthorship( + creator=self.submitter, + submitter_is_author=True + ), + SetLicense( + creator=self.submitter, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.submitter), + SetPrimaryClassification( + creator=self.submitter, + category='cs.DL' + ), + SetUploadPackage( + creator=self.submitter, + checksum="a9s9k342900skks03330029k", + source_format=domain.submission.SubmissionContent.Format('tex'), + identifier=123, + uncompressed_size=593992, + compressed_size=593992 + ), + SetTitle(creator=self.submitter, title=metadata['title']), + SetAbstract(creator=self.submitter, + abstract=metadata['abstract']), + SetComments(creator=self.submitter, + comments=metadata['comments']), + SetJournalReference( + creator=self.submitter, + journal_ref=metadata['journal_ref'] + ), + SetDOI(creator=self.submitter, doi=metadata['doi']), + SetReportNumber(creator=self.submitter, + report_num=metadata['report_num']), + SetAuthors( + creator=self.submitter, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.submitter) + ) + + # Now publish. + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + primary = self.submission.primary_classification.category + db_submission.document = classic.models.Document( + document_id=1, + paper_id='1901.00123', + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=primary, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_submission.doc_paper_id = '1901.00123' + session.add(db_submission) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_replacement(self): + """User has started a replacement submission.""" + with self.app.app_context(): + submission_to_replace, _ = load(self.submission.submission_id) + creation_event = CreateSubmissionVersion(creator=self.submitter) + replacement, _ = save(creation_event, + submission_id=self.submission.submission_id) + + with self.app.app_context(): + replacement, _ = load(replacement.submission_id) + + session = classic.current_session() + db_replacement = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.doc_paper_id + == replacement.arxiv_id) \ + .order_by(classic.models.Submission.submission_id.desc()) \ + .first() + + # Verify that the round-trip on the replacement submission worked + # as expected. + self.assertEqual(replacement.arxiv_id, + submission_to_replace.arxiv_id) + self.assertEqual(replacement.version, + submission_to_replace.version + 1) + self.assertEqual(replacement.status, Submission.WORKING) + self.assertTrue(submission_to_replace.is_announced) + self.assertFalse(replacement.is_announced) + + self.assertIsNone(replacement.source_content) + + self.assertFalse(replacement.submitter_contact_verified) + self.assertFalse(replacement.submitter_accepts_policy) + self.assertFalse(replacement.submitter_confirmed_preview) + self.assertFalse(replacement.submitter_contact_verified) + + # Verify that the database is in the right state for downstream + # integrations. + self.assertEqual(db_replacement.status, + classic.models.Submission.NEW) + self.assertEqual(db_replacement.type, + classic.models.Submission.REPLACEMENT) + self.assertEqual(db_replacement.doc_paper_id, '1901.00123') + + +class TestJREFIntegration(TestCase): + """Test integration with the classic database with JREF submissions.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + cls.app.config['WAIT_FOR_SERVICES'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """An arXiv user is submitting a new paper.""" + self.submitter = domain.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL']) + + # Create and finalize a new submission. + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + with self.app.app_context(): + classic.create_all() + metadata=dict([ + ('title', 'Foo title'), + ('abstract', "One morning, as Gregor Samsa was..."), + ('comments', '5 pages, 2 turtle doves'), + ('report_num', 'asdf1234') + ]) + self.submission, _ = save( + CreateSubmission(creator=self.submitter), + ConfirmContactInformation(creator=self.submitter), + ConfirmAuthorship( + creator=self.submitter, + submitter_is_author=True + ), + SetLicense( + creator=self.submitter, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.submitter), + SetPrimaryClassification( + creator=self.submitter, + category='cs.DL' + ), + SetUploadPackage( + creator=self.submitter, + checksum="a9s9k342900skks03330029k", + source_format=domain.submission.SubmissionContent.Format('tex'), + identifier=123, + uncompressed_size=593992, + compressed_size=593992 + ), + SetTitle(creator=self.submitter, + title=metadata['title']), + SetAbstract(creator=self.submitter, + abstract=metadata['abstract']), + SetComments(creator=self.submitter, + comments=metadata['comments']), + SetReportNumber(creator=self.submitter, + report_num=metadata['report_num']), + SetAuthors( + creator=self.submitter, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + ConfirmSourceProcessed( + creator=self.submitter, + source_id=123, + source_checksum="a9s9k342900skks03330029k", + preview_checksum="foopreviewchex==", + size_bytes=1234, + added=datetime.now(UTC) + ), + ConfirmPreview(creator=self.submitter, + preview_checksum="foopreviewchex=="), + FinalizeSubmission(creator=self.submitter) + ) + + # Now publish. + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + primary = self.submission.primary_classification.category + db_submission.document = classic.models.Document( + document_id=1, + paper_id='1901.00123', + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=primary, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_submission.doc_paper_id = '1901.00123' + session.add(db_submission) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_jref(self): + """User has started a JREF submission.""" + with self.app.app_context(): + session = classic.current_session() + submission_to_jref, _ = load(self.submission.submission_id) + event = SetJournalReference( + creator=self.submitter, + journal_ref='Foo Rev 1, 2 (1903)' + ) + jref_submission, _ = save( + event, + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + jref_submission, _ = load(jref_submission.submission_id) + session = classic.current_session() + db_jref = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.doc_paper_id + == jref_submission.arxiv_id) \ + .filter(classic.models.Submission.type + == classic.models.Submission.JOURNAL_REFERENCE) \ + .order_by(classic.models.Submission.submission_id.desc()) \ + .first() + + # Verify that the round-trip on the replacement submission worked + # as expected. + self.assertEqual(jref_submission.arxiv_id, + submission_to_jref.arxiv_id) + self.assertEqual(jref_submission.version, + submission_to_jref.version, + "The paper version should not change") + self.assertEqual(jref_submission.status, Submission.ANNOUNCED) + self.assertTrue(submission_to_jref.is_announced) + self.assertTrue(jref_submission.is_announced) + + self.assertIsNotNone(jref_submission.source_content) + + self.assertTrue(jref_submission.submitter_contact_verified) + self.assertTrue(jref_submission.submitter_accepts_policy) + self.assertTrue(jref_submission.submitter_confirmed_preview) + self.assertTrue(jref_submission.submitter_contact_verified) + + # Verify that the database is in the right state for downstream + # integrations. + self.assertEqual(db_jref.status, + classic.models.Submission.PROCESSING_SUBMISSION) + self.assertEqual(db_jref.type, + classic.models.Submission.JOURNAL_REFERENCE) + self.assertEqual(db_jref.doc_paper_id, '1901.00123') + self.assertEqual(db_jref.submitter_id, + jref_submission.creator.native_id) + + +class TestWithdrawalIntegration(TestCase): + """ + Test integration with the classic database concerning withdrawals. + + The :class:`.domain.submission.Submission` representation has only two + statuses: :attr:`.domain.submission.WITHDRAWAL_REQUESTED` and + :attr:`.domain.submission.WITHDRAWN`. Like other post-publish operations, + we are simply adding events to the single stream for the original + submission ID. This screens off details that are due to the underlying + implementation, and focuses on how humans are actually interacting with + withdrawals. + + On the classic side, we create a new row in the submission table for a + withdrawal request, and it passes through the same states as a regular + submission. + """ + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + cls.app.config['WAIT_FOR_SERVICES'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """An arXiv user is submitting a new paper.""" + self.submitter = domain.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL']) + + # Create and finalize a new submission. + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + with self.app.app_context(): + classic.create_all() + metadata=dict([ + ('title', 'Foo title'), + ('abstract', "One morning, as Gregor Samsa was..."), + ('comments', '5 pages, 2 turtle doves'), + ('report_num', 'asdf1234'), + ('doi', '10.01234/56789'), + ('journal_ref', 'Foo Rev 1, 2 (1903)') + ]) + self.submission, _ = save( + CreateSubmission(creator=self.submitter), + ConfirmContactInformation(creator=self.submitter), + ConfirmAuthorship( + creator=self.submitter, + submitter_is_author=True + ), + SetLicense( + creator=self.submitter, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.submitter), + SetPrimaryClassification( + creator=self.submitter, + category='cs.DL' + ), + SetUploadPackage( + creator=self.submitter, + checksum="a9s9k342900skks03330029k", + source_format=domain.submission.SubmissionContent.Format('tex'), + identifier=123, + uncompressed_size=593992, + compressed_size=593992 + ), + SetTitle(creator=self.submitter, title=metadata['title']), + SetAbstract(creator=self.submitter, + abstract=metadata['abstract']), + SetComments(creator=self.submitter, + comments=metadata['comments']), + SetJournalReference( + creator=self.submitter, + journal_ref=metadata['journal_ref'] + ), + SetDOI(creator=self.submitter, doi=metadata['doi']), + SetReportNumber(creator=self.submitter, + report_num=metadata['report_num']), + SetAuthors( + creator=self.submitter, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.submitter) + ) + self.submission_id = self.submission.submission_id + + # Announce. + with self.app.app_context(): + session = classic.current_session() + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + primary = self.submission.primary_classification.category + db_submission.document = classic.models.Document( + document_id=1, + paper_id='1901.00123', + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=primary, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_submission.doc_paper_id = '1901.00123' + session.add(db_submission) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_request_withdrawal(self): + """Request a withdrawal.""" + with self.app.app_context(): + session = classic.current_session() + event = RequestWithdrawal(creator=self.submitter, + reason="short people got no reason") + submission, _ = save(event, submission_id=self.submission_id) + + submission, _ = load(self.submission_id) + self.assertEqual(submission.status, domain.Submission.ANNOUNCED) + request = list(submission.user_requests.values())[0] + self.assertEqual(request.reason_for_withdrawal, event.reason) + + wdr = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.doc_paper_id == submission.arxiv_id) \ + .order_by(classic.models.Submission.submission_id.desc()) \ + .first() + self.assertEqual(wdr.status, + classic.models.Submission.PROCESSING_SUBMISSION) + self.assertEqual(wdr.type, classic.models.Submission.WITHDRAWAL) + self.assertIn(f"Withdrawn: {event.reason}", wdr.comments) + + +class TestPublicationIntegration(TestCase): + """ + Test integration with the classic database concerning publication. + + Since the publication process continues to run outside of the event model + in the short term, we need to be certain that publication-related changes + are represented accurately in this project. + """ + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + cls.app.config['WAIT_FOR_SERVICES'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """An arXiv user is submitting a new paper.""" + self.submitter = domain.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL']) + + # Create and finalize a new submission. + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + with self.app.app_context(): + classic.create_all() + metadata=dict([ + ('title', 'Foo title'), + ('abstract', "One morning, as Gregor Samsa was..."), + ('comments', '5 pages, 2 turtle doves'), + ('report_num', 'asdf1234'), + ('doi', '10.01234/56789'), + ('journal_ref', 'Foo Rev 1, 2 (1903)') + ]) + self.submission, _ = save( + CreateSubmission(creator=self.submitter), + ConfirmContactInformation(creator=self.submitter), + ConfirmAuthorship( + creator=self.submitter, + submitter_is_author=True + ), + SetLicense( + creator=self.submitter, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.submitter), + SetPrimaryClassification( + creator=self.submitter, + category='cs.DL' + ), + SetUploadPackage( + creator=self.submitter, + checksum="a9s9k342900skks03330029k", + source_format=domain.submission.SubmissionContent.Format('tex'), + identifier=123, + uncompressed_size=593992, + compressed_size=593992 + ), + SetTitle(creator=self.submitter, + title=metadata['title']), + SetAbstract(creator=self.submitter, + abstract=metadata['abstract']), + SetComments(creator=self.submitter, + comments=metadata['comments']), + SetJournalReference( + creator=self.submitter, + journal_ref=metadata['journal_ref'] + ), + SetDOI(creator=self.submitter, doi=metadata['doi']), + SetReportNumber(creator=self.submitter, + report_num=metadata['report_num']), + SetAuthors( + creator=self.submitter, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.submitter) + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_publication_status_is_reflected(self): + """The submission has been announced/announced.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + primary = self.submission.primary_classification.category + db_submission.document = classic.models.Document( + document_id=1, + paper_id='1901.00123', + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=primary, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + session.add(db_submission) + session.commit() + + # Submission state should reflect publication status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.ANNOUNCED, + "Submission should have announced status.") + self.assertEqual(submission.arxiv_id, "1901.00123", + "arXiv paper ID should be set") + self.assertFalse(submission.is_active, + "Announced submission should no longer be active") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_publication_status_is_reflected_after_files_expire(self): + """The submission has been announced/announced, and files expired.""" + paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.DELETED_ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + primary = self.submission.primary_classification.category + db_submission.document = classic.models.Document( + document_id=1, + paper_id=paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=primary, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_submission.doc_paper_id = paper_id + session.add(db_submission) + session.commit() + + # Submission state should reflect publication status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.ANNOUNCED, + "Submission should have announced status.") + self.assertEqual(submission.arxiv_id, "1901.00123", + "arXiv paper ID should be set") + self.assertFalse(submission.is_active, + "Announced submission should no longer be active") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_scheduled_status_is_reflected(self): + """The submission has been scheduled for publication today.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.PROCESSING + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.SCHEDULED, + "Submission should have scheduled status.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_scheduled_status_is_reflected_processing_submission(self): + """The submission has been scheduled for publication today.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.PROCESSING_SUBMISSION + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.SCHEDULED, + "Submission should have scheduled status.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_scheduled_status_is_reflected_prior_to_announcement(self): + """The submission is being announced; not yet announced.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.NEEDS_EMAIL + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.SCHEDULED, + "Submission should have scheduled status.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_scheduled_tomorrow_status_is_reflected(self): + """The submission has been scheduled for publication tomorrow.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.NEXT_PUBLISH_DAY + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.SCHEDULED, + "Submission should be scheduled for tomorrow.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_publication_failed(self): + """The submission was not announced successfully.""" + with self.app.app_context(): + session = classic.current_session() + + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = db_submission.ERROR_STATE + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.ERROR, + "Submission should have error status.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_deleted(self): + """The submission was deleted by the classic system.""" + with self.app.app_context(): + session = classic.current_session() + + for classic_status in classic.models.Submission.DELETED: + # Publication agent publishes the paper. + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + db_submission.status = classic_status + session.add(db_submission) + session.commit() + + # Submission state should reflect scheduled status. + submission, _ = load(self.submission.submission_id) + self.assertEqual(submission.status, submission.DELETED, + "Submission should have deleted status.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_deleted_in_ng(self): + """The submission was deleted in this package.""" + with self.app.app_context(): + session = classic.current_session() + self.submission, _ = save( + Rollback(creator=self.submitter), + submission_id=self.submission.submission_id + ) + + db_submission = session.query(classic.models.Submission)\ + .get(self.submission.submission_id) + self.assertEqual(db_submission.status, + classic.models.Submission.USER_DELETED) diff --git a/src/arxiv/submission/tests/examples/__init__.py b/src/arxiv/submission/tests/examples/__init__.py new file mode 100644 index 0000000..97fd7ed --- /dev/null +++ b/src/arxiv/submission/tests/examples/__init__.py @@ -0,0 +1,7 @@ +""" +Tests based on user workflow examples. + +The tests in this version of the project assume that we are working in the NG +paradigm for submissions only, and that moderation, publication, etc continue +to rely on the classic model. +""" diff --git a/src/arxiv/submission/tests/examples/test_01_working_submission.py b/src/arxiv/submission/tests/examples/test_01_working_submission.py new file mode 100644 index 0000000..3572f28 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_01_working_submission.py @@ -0,0 +1,180 @@ +"""Example 1: working submission.""" + +from unittest import TestCase, mock +import tempfile + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions +from ... import core + + +class TestWorkingSubmission(TestCase): + """ + Submitter creates a new submission, has completed some but not all fields. + + This is a typical scenario in which the user has missed a step, or left + something required blank. These should get caught early if we designed + the UI or API right, but it's possible that something slipped through. + """ + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create and partially complete the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title='the best title', **self.defaults) + ) + self.submission_id = self.submission.submission_id + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_working_state(self): + """The submission in in the working state.""" + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.NOT_SUBMITTED, + "The classic submission is in the not submitted" + " state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_delete(self): + """The submission can be deleted.""" + with self.app.app_context(): + save(domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + + self.assertEqual(submission.status, + domain.event.Submission.DELETED, + "Submission is in the deleted state") + self.assertFalse(submission.is_active, + "The submission is no longer considered active.") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission_id) + self.assertEqual(submission.status, + domain.event.Submission.DELETED, + "Submission is in the deleted state") + self.assertFalse(submission.is_active, + "The submission is no longer considered active.") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.USER_DELETED, + "The classic submission is in the DELETED state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_finalize_submission(self): + """The submission cannot be finalized.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a FinalizeSubmission command results in an" + " exception.")): + save(domain.event.FinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_replace_submission(self): + """The submission cannot be replaced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a CreateSubmissionVersion command results in an" + " exception.")): + save(domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_withdraw_submission(self): + """The submission cannot be withdrawn.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a RequestWithdrawal command results in an" + " exception.")): + save(domain.event.RequestWithdrawal(reason="the best reason", + **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_be_unfinalized(self): + """The submission cannot be unfinalized.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating an UnFinalizeSubmission command results in an" + " exception.")): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_working_state() diff --git a/src/arxiv/submission/tests/examples/test_02_finalized_submission.py b/src/arxiv/submission/tests/examples/test_02_finalized_submission.py new file mode 100644 index 0000000..043c3e4 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_02_finalized_submission.py @@ -0,0 +1,200 @@ +"""Example 2: finalized submission.""" + +from unittest import TestCase, mock +import tempfile + +from flask import Flask + +from ...services import classic, StreamPublisher + +from ... import save, load, load_fast, domain, exceptions +from ... import core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' + + +class TestFinalizedSubmission(TestCase): + """ + Submitter creates, completes, and finalizes a new submission. + + At this point the submission is in the queue for moderation and + announcement. + """ + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, and complete the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category="cs.DL", + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_submitted_state(self): + """ + The submission is now submitted. + + This moves the submission into consideration for announcement, and + is visible to moderators. + """ + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.SUBMITTED, + "The submission is in the submitted state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.SUBMITTED, + "The submission is in the submitted state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.SUBMITTED, + "The classic submission is in the SUBMITTED" + " state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_replace_submission(self): + """The submission cannot be replaced: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a CreateSubmissionVersion command results in an" + " exception.")): + save(domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_withdraw_submission(self): + """The submission cannot be withdrawn: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a RequestWithdrawal command results in an" + " exception.")): + save(domain.event.RequestWithdrawal(reason="the best reason", + **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_edit_submission(self): + """The submission cannot be changed: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetTitle command results in an exception.")): + save(domain.event.SetTitle(title="A better title", + **self.defaults), + submission_id=self.submission.submission_id) + + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetDOI command results in an exception.")): + save(domain.event.SetDOI(doi="10.1000/182", **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_be_unfinalized(self): + """The submission can be unfinalized.""" + with self.app.app_context(): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.NOT_SUBMITTED, + "The classic submission is in the not submitted" + " state") diff --git a/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py b/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py new file mode 100644 index 0000000..009f9f8 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py @@ -0,0 +1,205 @@ +"""Example 3: submission on hold.""" + +from unittest import TestCase, mock +import tempfile + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' + + +class TestOnHoldSubmission(TestCase): + """ + Submitter finalizes a new submission; the system places it on hold. + + This can be due to a variety of reasons. + """ + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, and complete the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category="cs.DL", + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Place the submission on hold. + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ON_HOLD + session.add(db_row) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_submitted_state(self): + """The submission is now on hold.""" + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + # self.assertEqual(submission.status, + # domain.submission.Submission.ON_HOLD, + # "The submission is in the hold state") + self.assertTrue(submission.is_on_hold, "The submission is on hold") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + # self.assertEqual(submission.status, + # domain.submission.Submission.ON_HOLD, + # "The submission is in the hold state") + self.assertTrue(submission.is_on_hold, "The submission is on hold") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.ON_HOLD, + "The classic submission is in the ON_HOLD" + " state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_replace_submission(self): + """The submission cannot be replaced: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a CreateSubmissionVersion command results in an" + " exception.")): + save(domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_withdraw_submission(self): + """The submission cannot be withdrawn: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a RequestWithdrawal command results in an" + " exception.")): + save(domain.event.RequestWithdrawal(reason="the best reason", + **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_edit_submission(self): + """The submission cannot be changed: it hasn't yet been announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetTitle command results in an exception.")): + save(domain.event.SetTitle(title="A better title", + **self.defaults), + submission_id=self.submission.submission_id) + + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetDOI command results in an exception.")): + save(domain.event.SetDOI(doi="10.1000/182", **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_submitted_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_be_unfinalized(self): + """The submission can be unfinalized.""" + with self.app.app_context(): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(len(submission.versions), 0, + "There are no announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.NOT_SUBMITTED, + "The classic submission is in the not submitted" + " state") + self.assertEqual(row.sticky_status, + classic.models.Submission.ON_HOLD, + "The hold is preserved as a sticky status") diff --git a/src/arxiv/submission/tests/examples/test_04_published_submission.py b/src/arxiv/submission/tests/examples/test_04_published_submission.py new file mode 100644 index 0000000..6891a63 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_04_published_submission.py @@ -0,0 +1,531 @@ +"""Example 4: submission is announced.""" + +from unittest import TestCase, mock +import tempfile +from datetime import datetime +from pytz import UTC + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' + + +class TestAnnouncedSubmission(TestCase): + """Submitter finalizes a new submission, and it is eventually announced.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_announced_state(self): + """The submission is now announced.""" + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state") + self.assertTrue(submission.is_announced, "Submission is announced") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state") + self.assertTrue(submission.is_announced, "Submission is announced") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission).all() + + self.assertEqual(len(db_rows), 1, + "There is one row in the submission table") + row = db_rows[0] + self.assertEqual(row.type, + classic.models.Submission.NEW_SUBMISSION, + "The classic submission has type 'new'") + self.assertEqual(row.status, + classic.models.Submission.ANNOUNCED, + "The classic submission is in the ANNOUNCED" + " state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_replace_submission(self): + """The submission can be replaced, resulting in a new version.""" + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.NOT_SUBMITTED, + "The second row is in not submitted state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_withdraw_submission(self): + """The submitter can request withdrawal of the submission.""" + withdrawal_reason = "the best reason" + with self.app.app_context(): + submission, events = save( + domain.event.RequestWithdrawal(reason=withdrawal_reason, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.WITHDRAWAL, + "The second row has type 'withdrawal'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The second row is in the processing submission" + " state.") + + # Cannot submit another withdrawal request while one is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestWithdrawal(reason="more reason", + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_request_crosslist(self): + """The submitter can request cross-list classification.""" + category = "cs.IR" + with self.app.app_context(): + submission, events = save( + domain.event.RequestCrossList(categories=[category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The second row is in the processing submission" + " state.") + + # Cannot submit another cross-list request while one is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestCrossList(categories=["q-fin.CP"], + **self.defaults), + submission_id=self.submission.submission_id) + + # Cannot submit a withdrawal request while a cross-list is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestWithdrawal(reason="more reason", + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_edit_submission_metadata(self): + """The submission metadata cannot be changed without a new version.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetTitle command results in an exception.")): + save(domain.event.SetTitle(title="A better title", + **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_announced_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_changing_doi(self): + """Submitter can set the DOI.""" + new_doi = "10.1000/182" + new_journal_ref = "Baz 1993" + new_report_num = "Report 82" + with self.app.app_context(): + submission, events = save( + domain.event.SetDOI(doi=new_doi, **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetJournalReference(journal_ref=new_journal_ref, + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetReportNumber(report_num=new_report_num, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.JOURNAL_REFERENCE, + "The second row has type journal ref") + self.assertEqual(db_rows[1].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The second row is in the processing submission" + " state.") + self.assertEqual(db_rows[1].doi, new_doi, + "The DOI is updated in the database.") + self.assertEqual(db_rows[1].journal_ref, new_journal_ref, + "The journal ref is updated in the database.") + self.assertEqual(db_rows[1].report_num, new_report_num, + "The report number is updated in the database.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_be_unfinalized(self): + """The submission cannot be unfinalized, because it is announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_announced_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_rolling_back_does_not_clobber_jref_changes(self): + """If user submits a JREF, rolling back does not clobber changes.""" + # These changes result in what we consider a "JREF submission" in + # classic. But we're moving away from that way of thinking in NG, so + # it should be somewhat opaque in a replacement/deletion scenario. + new_doi = "10.1000/182" + new_journal_ref = "Baz 1993" + new_report_num = "Report 82" + with self.app.app_context(): + submission, events = save( + domain.event.SetDOI(doi=new_doi, **self.defaults), + domain.event.SetJournalReference(journal_ref=new_journal_ref, + **self.defaults), + domain.event.SetReportNumber(report_num=new_report_num, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Now we get a replacement. + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + domain.event.SetTitle(title='A new and better title', + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Now the user rolls back the replacement. + with self.app.app_context(): + submission, events = save( + domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. The JREF changes shoulds stick. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is still updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is still updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is stil updated.") + self.assertEqual(submission.metadata.title, + self.submission.metadata.title, + "The title is reverted to the last announced" + " version.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is still updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is still updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is stil updated.") + self.assertEqual(submission.metadata.title, + self.submission.metadata.title, + "The title is reverted to the last announced" + " version.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") diff --git a/src/arxiv/submission/tests/examples/test_05_working_replacement.py b/src/arxiv/submission/tests/examples/test_05_working_replacement.py new file mode 100644 index 0000000..9f33d31 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_05_working_replacement.py @@ -0,0 +1,465 @@ +"""Example 5: submission is being replaced.""" + +from unittest import TestCase, mock +import tempfile +from datetime import datetime +from pytz import UTC + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' + + +class TestReplacementSubmissionInProgress(TestCase): + """Submitter creates a replacement, and is working on updates.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + with self.app.app_context(): + self.submission, self.events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_working_state(self): + """The submission is now in working state.""" + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertIsInstance(self.events[-2], domain.event.Announce, + "An Announce event is inserted.") + self.assertIsInstance(self.events[-1], + domain.event.CreateSubmissionVersion, + "A CreateSubmissionVersion event is" + " inserted.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertIsInstance(self.events[-2], domain.event.Announce, + "An Announce event is inserted.") + self.assertIsInstance(self.events[-1], + domain.event.CreateSubmissionVersion, + "A CreateSubmissionVersion event is" + " inserted.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.NOT_SUBMITTED, + "The second row is in not submitted state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_replace_submission_again(self): + """The submission cannot be replaced again while in working state.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + self.submission, self.events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_withdraw_submission(self): + """The submitter cannot request withdrawal of the submission.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + submission, events = save( + domain.event.RequestWithdrawal(reason="the best reason", + **self.defaults), + submission_id=self.submission.submission_id + ) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_edit_submission_metadata(self): + """The submission metadata can now be changed.""" + new_title = "A better title" + with self.app.app_context(): + submission, events = save( + domain.event.SetTitle(title=new_title, **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.metadata.title, new_title, + "The submission is changed") + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertIsInstance(events[-3], domain.event.Announce, + "An Announce event is inserted.") + self.assertIsInstance(events[-2], + domain.event.CreateSubmissionVersion, + "A CreateSubmissionVersion event is" + " inserted.") + self.assertIsInstance(events[-1], + domain.event.SetTitle, + "Metadata update events are reflected") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.metadata.title, new_title, + "The submission is changed") + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertIsInstance(events[-3], domain.event.Announce, + "An Announce event is inserted.") + self.assertIsInstance(events[-2], + domain.event.CreateSubmissionVersion, + "A CreateSubmissionVersion event is" + " inserted.") + self.assertIsInstance(events[-1], + domain.event.SetTitle, + "Metadata update events are reflected") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[0].title, self.submission.metadata.title, + "Announced row is unchanged.") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.NOT_SUBMITTED, + "The second row is in not submitted state") + self.assertEqual(db_rows[1].title, new_title, + "Replacement row reflects the change.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_changing_doi(self): + """Submitter can set the DOI as part of the new version.""" + new_doi = "10.1000/182" + new_journal_ref = "Baz 1993" + new_report_num = "Report 82" + with self.app.app_context(): + submission, events = save( + domain.event.SetDOI(doi=new_doi, **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetJournalReference(journal_ref=new_journal_ref, + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetReportNumber(report_num=new_report_num, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state.") + + self.assertIsInstance(events[-5], domain.event.Announce, + "An Announce event is inserted.") + self.assertIsInstance(events[-4], + domain.event.CreateSubmissionVersion, + "A CreateSubmissionVersion event is" + " inserted.") + self.assertIsInstance(events[-3], + domain.event.SetDOI, + "Metadata update events are reflected") + self.assertIsInstance(events[-2], + domain.event.SetJournalReference, + "Metadata update events are reflected") + self.assertIsInstance(events[-1], + domain.event.SetReportNumber, + "Metadata update events are reflected") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type replacement") + self.assertEqual(db_rows[1].status, + classic.models.Submission.NOT_SUBMITTED, + "The second row is in the not submitted state.") + self.assertEqual(db_rows[1].doi, new_doi, + "The DOI is updated in the database.") + self.assertEqual(db_rows[1].journal_ref, new_journal_ref, + "The journal ref is updated in the database.") + self.assertEqual(db_rows[1].report_num, new_report_num, + "The report number is updated in the database.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_be_unfinalized(self): + """The submission cannot be unfinalized, as it is not finalized.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_working_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_revert_to_most_recent_announced_version(self): + """Submitter can abandon changes to their replacement.""" + new_doi = "10.1000/182" + new_journal_ref = "Baz 1993" + new_report_num = "Report 82" + with self.app.app_context(): + submission, events = save( + domain.event.SetDOI(doi=new_doi, **self.defaults), + domain.event.SetJournalReference(journal_ref=new_journal_ref, + **self.defaults), + domain.event.SetReportNumber(report_num=new_report_num, + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.version, 1, + "Version number is rolled back") + self.assertEqual(submission.metadata.doi, + self.submission.metadata.doi, + "The DOI is reverted.") + self.assertEqual(submission.metadata.journal_ref, + self.submission.metadata.journal_ref, + "The journal ref is reverted.") + self.assertEqual(submission.metadata.report_num, + self.submission.metadata.report_num, + "The report number is reverted.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.version, 1, + "Version number is rolled back") + self.assertEqual(submission.metadata.doi, + self.submission.metadata.doi, + "The DOI is reverted.") + self.assertEqual(submission.metadata.journal_ref, + self.submission.metadata.journal_ref, + "The journal ref is reverted.") + self.assertEqual(submission.metadata.report_num, + self.submission.metadata.report_num, + "The report number is reverted.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type replacement") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is in the user deleted state.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_start_a_new_replacement_after_reverting(self): + """Submitter can start a new replacement after reverting.""" + with self.app.app_context(): + submission, events = save( + domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.version, 2, + "Version number is incremented.") + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "Submission is in working state") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") diff --git a/src/arxiv/submission/tests/examples/test_06_second_version_published.py b/src/arxiv/submission/tests/examples/test_06_second_version_published.py new file mode 100644 index 0000000..62555f9 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_06_second_version_published.py @@ -0,0 +1,420 @@ +"""Example 6: second version of a submission is announced.""" + +from unittest import TestCase, mock +import tempfile +from datetime import datetime +from pytz import UTC + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' + + +class TestSecondVersionIsAnnounced(TestCase): + """Submitter creates a replacement, and it is announced.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create and publish two versions.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.drop_all() + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + with self.app.app_context(): + new_title = "A better title" + self.submission, self.events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=new_title, **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Announce second version. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + db_rows[1].status = classic.models.Submission.ANNOUNCED + session.add(db_rows[1]) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_is_in_announced_state(self): + """The submission is now in announced state.""" + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the publushed state") + self.assertIsInstance(events[-1], domain.event.Announce, + "An Announce event is inserted.") + p_evts = [e for e in events if isinstance(e, domain.event.Announce)] + self.assertEqual(len(p_evts), 2, "There are two publish events.") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the publushed state") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in announced state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_replace_submission(self): + """The submission can be replaced, resulting in a new version.""" + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 3, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 3, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.REPLACEMENT, + "The third row has type 'replacement'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.NOT_SUBMITTED, + "The third row is in not submitted state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_withdraw_submission(self): + """The submitter can request withdrawal of the submission.""" + withdrawal_reason = "the best reason" + with self.app.app_context(): + submission, events = save( + domain.event.RequestWithdrawal(reason=withdrawal_reason, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.WITHDRAWAL, + "The third row has type 'withdrawal'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_edit_submission_metadata(self): + """The submission metadata cannot be changed without a new version.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent, msg=( + "Creating a SetTitle command results in an exception.")): + save(domain.event.SetTitle(title="A better title", + **self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_announced_state() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_changing_doi(self): + """Submitter can set the DOI.""" + new_doi = "10.1000/182" + new_journal_ref = "Baz 1993" + new_report_num = "Report 82" + with self.app.app_context(): + submission, events = save( + domain.event.SetDOI(doi=new_doi, **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetJournalReference(journal_ref=new_journal_ref, + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = save( + domain.event.SetReportNumber(report_num=new_report_num, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.metadata.doi, new_doi, + "The DOI is updated.") + self.assertEqual(submission.metadata.journal_ref, new_journal_ref, + "The journal ref is updated.") + self.assertEqual(submission.metadata.report_num, new_report_num, + "The report number is updated.") + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is in the submitted state.") + self.assertEqual(len(submission.versions), 2, + "There are two announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.JOURNAL_REFERENCE, + "The third row has type journal ref") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") + self.assertEqual(db_rows[2].doi, new_doi, + "The DOI is updated in the database.") + self.assertEqual(db_rows[2].journal_ref, new_journal_ref, + "The journal ref is updated in the database.") + self.assertEqual(db_rows[2].report_num, new_report_num, + "The report number is updated in the database.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_be_unfinalized(self): + """The submission cannot be unfinalized, because it is announced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.UnFinalizeSubmission(**self.defaults), + submission_id=self.submission.submission_id) + + self.test_is_in_announced_state() diff --git a/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py b/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py new file mode 100644 index 0000000..5d416a6 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py @@ -0,0 +1,1086 @@ +"""Example 7: cross-list request.""" + +from unittest import TestCase, mock +import tempfile +from datetime import datetime +from pytz import UTC + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' +TEX = domain.submission.SubmissionContent.Format('tex') + + +class TestCrossListRequested(TestCase): + """Submitter has requested that a cross-list classification be added.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + # Request cross-list classification + self.category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestCrossList(categories=[self.category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_has_pending_requests(self): + """The submission has an outstanding publication.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The second row is in the processing submission" + " state.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_replace_submission(self): + """The submission cannot be replaced.""" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_withdraw_submission(self): + """The submitter cannot request withdrawal.""" + withdrawal_reason = "the best reason" + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestWithdrawal(reason=withdrawal_reason, + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_cannot_request_another_crosslist(self): + """The submitter cannot request a second cross-list.""" + # Cannot submit another cross-list request while one is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestCrossList(categories=["q-fin.CP"], + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_request_is_rejected(self): + """If the request is 'removed' in classic, NG request is rejected.""" + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + db_rows[1].status = classic.models.Submission.REMOVED + session.add(db_rows[1]) + session.commit() + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There are no pending user request.") + self.assertEqual(len(submission.rejected_user_requests), 1, + "There is one rejected user request.") + self.assertIsInstance( + submission.rejected_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.rejected_user_requests[0].categories, + "Requested category is set on request.") + self.assertNotIn(self.category, submission.secondary_categories, + "Requested category is not added to submission") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There are no pending user request.") + self.assertEqual(len(submission.rejected_user_requests), 1, + "There is one rejected user request.") + self.assertIsInstance( + submission.rejected_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.rejected_user_requests[0].categories, + "Requested category is set on request.") + self.assertNotIn(self.category, submission.secondary_categories, + "Requested category is not added to submission") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_request_is_applied(self): + """If the request is announced in classic, NG request is 'applied'.""" + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + db_rows[1].status = classic.models.Submission.ANNOUNCED + session.add(db_rows[1]) + session.commit() + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There are no pending user request.") + self.assertEqual(len(submission.applied_user_requests), 1, + "There is one applied user request.") + self.assertIsInstance( + submission.applied_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.applied_user_requests[0].categories, + "Requested category is set on request.") + self.assertIn(self.category, submission.secondary_categories, + "Requested category is added to submission") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There are no pending user request.") + self.assertEqual(len(submission.applied_user_requests), 1, + "There is one applied user request.") + self.assertIsInstance( + submission.applied_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.applied_user_requests[0].categories, + "Requested category is set on request.") + self.assertIn(self.category, submission.secondary_categories, + "Requested category is added to submission") + + +class TestCrossListApplied(TestCase): + """Request for cross-list has been approved and applied.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + # Request cross-list classification + self.category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestCrossList(categories=[self.category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Apply. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + db_rows[1].status = classic.models.Submission.ANNOUNCED + session.add(db_rows[1]) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_has_applied_requests(self): + """The submission has an applied request.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.applied_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.applied_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.applied_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.applied_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.applied_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.applied_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in the processing submission" + " state.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_replace_submission(self): + """The submission can be replaced, resulting in a new version.""" + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in the announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.REPLACEMENT, + "The third row has type 'replacement'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.NOT_SUBMITTED, + "The third row is in not submitted state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_withdraw_submission(self): + """The submitter can request withdrawal of the submission.""" + withdrawal_reason = "the best reason" + with self.app.app_context(): + submission, events = save( + domain.event.RequestWithdrawal(reason=withdrawal_reason, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in the announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.WITHDRAWAL, + "The third row has type 'withdrawal'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") + + # Cannot submit another withdrawal request while one is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestWithdrawal(reason="more reason", + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_request_crosslist(self): + """The submitter can request cross-list classification.""" + category = "cs.LO" + with self.app.app_context(): + submission, events = save( + domain.event.RequestCrossList(categories=[category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.ANNOUNCED, + "The second row is in the announced state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.CROSS_LIST, + "The third row has type 'cross'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") + + +class TestCrossListRejected(TestCase): + """Request for cross-list has been rejected.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + # Request cross-list classification + self.category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestCrossList(categories=[self.category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Apply. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + db_rows[1].status = classic.models.Submission.REMOVED + session.add(db_rows[1]) + session.commit() + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_has_rejected_request(self): + """The submission has a rejected request.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There is are no pending user requests.") + self.assertEqual(len(submission.rejected_user_requests), 1, + "There is one rejected user request.") + self.assertIsInstance( + submission.rejected_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.rejected_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertFalse(submission.has_active_requests, + "The submission has no active requests.") + self.assertEqual(len(submission.pending_user_requests), 0, + "There is are no pending user requests.") + self.assertEqual(len(submission.rejected_user_requests), 1, + "There is one rejected user request.") + self.assertIsInstance( + submission.rejected_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(self.category, + submission.rejected_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.REMOVED, + "The second row is in the removed state.") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_replace_submission(self): + """The submission can be replaced, resulting in a new version.""" + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is in the working state") + self.assertEqual(submission.version, 2, + "The version number is incremented by 1") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.REMOVED, + "The second row is in the removed state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.REPLACEMENT, + "The third row has type 'replacement'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.NOT_SUBMITTED, + "The third row is in not submitted state") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_withdraw_submission(self): + """The submitter can request withdrawal of the submission.""" + withdrawal_reason = "the best reason" + with self.app.app_context(): + submission, events = save( + domain.event.RequestWithdrawal(reason=withdrawal_reason, + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance(submission.pending_user_requests[0], + domain.submission.WithdrawalRequest) + self.assertEqual( + submission.pending_user_requests[0].reason_for_withdrawal, + withdrawal_reason, + "Withdrawal reason is set on request." + ) + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.REMOVED, + "The second row is in the removed state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.WITHDRAWAL, + "The third row has type 'withdrawal'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") + + # Cannot submit another withdrawal request while one is pending. + with self.app.app_context(): + with self.assertRaises(exceptions.InvalidEvent): + save(domain.event.RequestWithdrawal(reason="more reason", + **self.defaults), + submission_id=self.submission.submission_id) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_request_crosslist(self): + """The submitter can request cross-list classification.""" + category = "cs.LO" + with self.app.app_context(): + submission, events = save( + domain.event.RequestCrossList(categories=[category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + # Check the submission state. + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is announced.") + self.assertTrue(submission.has_active_requests, + "The submission has an active request.") + self.assertEqual(len(submission.pending_user_requests), 1, + "There is one pending user request.") + self.assertIsInstance( + submission.pending_user_requests[0], + domain.submission.CrossListClassificationRequest + ) + self.assertIn(category, + submission.pending_user_requests[0].categories, + "Requested category is set on request.") + self.assertEqual(len(submission.versions), 1, + "There is one announced versions") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is announced") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.REMOVED, + "The second row is in the removed state") + self.assertEqual(db_rows[2].type, + classic.models.Submission.CROSS_LIST, + "The third row has type 'cross'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is in the processing submission" + " state.") diff --git a/src/arxiv/submission/tests/examples/test_10_abandon_submission.py b/src/arxiv/submission/tests/examples/test_10_abandon_submission.py new file mode 100644 index 0000000..ce905d4 --- /dev/null +++ b/src/arxiv/submission/tests/examples/test_10_abandon_submission.py @@ -0,0 +1,682 @@ +"""Example 10: abandoning submissions and requests.""" + +from unittest import TestCase, mock +import tempfile +from datetime import datetime +from pytz import UTC + +from flask import Flask + +from ...services import classic +from ... import save, load, load_fast, domain, exceptions, core + +CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' +TEX = domain.submission.SubmissionContent.Format('tex') + + +class TestAbandonSubmission(TestCase): + """Submitter has started a submission.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults) + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_abandon_new_submission(self): + """Submitter abandons new submission.""" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.DELETED, + "The submission is DELETED.") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.DELETED, + "The submission is DELETED.") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 1, + "There are one rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.USER_DELETED, + "The first row is USER_DELETED") + + +class TestAbandonReplacement(TestCase): + """Submitter has started a replacement and then rolled it back.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + self.submission, self.events = save( + domain.event.Rollback(**self.defaults), + submission_id=self.submission.submission_id + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_abandon_replacement_submission(self): + """The replacement is cancelled.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + self.assertEqual(submission.version, 1, "Back to v1") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + self.assertEqual(submission.version, 1, "Back to v1") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_can_start_new_replacement(self): + """The user can start a new replacement.""" + with self.app.app_context(): + submission, events = save( + domain.event.CreateSubmissionVersion(**self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is WORKING.") + self.assertEqual(submission.version, 2, "On to v2") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.WORKING, + "The submission is WORKING.") + self.assertEqual(submission.version, 2, "On to v2") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are three rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.REPLACEMENT, + "The second row has type 'replacement'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + self.assertEqual(db_rows[2].type, + classic.models.Submission.REPLACEMENT, + "The third row has type 'replacement'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.NOT_SUBMITTED, + "The third row is NOT_SUBMITTED") + + +class TestCrossListCancelled(TestCase): + """Submitter has created and cancelled a cross-list request.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + # Request cross-list classification + category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestCrossList(categories=[category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + request_id = self.submission.active_user_requests[0].request_id + self.submission, self.events = save( + domain.event.CancelRequest(request_id=request_id, + **self.defaults), + submission_id=self.submission.submission_id + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_request_is_cancelled(self): + """Submitter has cancelled the cross-list request.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_user_can_make_another_request(self): + """User can now make another request.""" + # Request cross-list classification + category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestCrossList(categories=[category], + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.CROSS_LIST, + "The second row has type 'cross'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + self.assertEqual(db_rows[2].type, + classic.models.Submission.CROSS_LIST, + "The third row has type 'cross'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is PROCESSING_SUBMISSION") + + +class TestWithdrawalCancelled(TestCase): + """Submitter has created and cancelled a withdrawal request.""" + + @classmethod + def setUpClass(cls): + """Instantiate an app for use with a SQLite database.""" + _, db = tempfile.mkstemp(suffix='.sqlite') + cls.app = Flask('foo') + cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' + cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with cls.app.app_context(): + classic.init_app(cls.app) + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create, complete, and publish the submission.""" + self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', + forename='Jane', surname='User', + endorsements=['cs.DL', 'cs.IR']) + self.defaults = {'creator': self.submitter} + with self.app.app_context(): + classic.create_all() + self.title = "the best title" + self.doi = "10.01234/56789" + self.category = "cs.DL" + self.submission, self.events = save( + domain.event.CreateSubmission(**self.defaults), + domain.event.ConfirmContactInformation(**self.defaults), + domain.event.ConfirmAuthorship(**self.defaults), + domain.event.ConfirmPolicy(**self.defaults), + domain.event.SetTitle(title=self.title, **self.defaults), + domain.event.SetLicense(license_uri=CCO, + license_name="CC0 1.0", + **self.defaults), + domain.event.SetPrimaryClassification(category=self.category, + **self.defaults), + domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", + source_format=TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=593992, + **self.defaults), + domain.event.SetAbstract(abstract="Very abstract " * 20, + **self.defaults), + domain.event.SetComments(comments="Fine indeed " * 10, + **self.defaults), + domain.event.SetJournalReference(journal_ref="Foo 1992", + **self.defaults), + domain.event.SetDOI(doi=self.doi, **self.defaults), + domain.event.SetAuthors(authors_display='Robert Paulson (FC)', + **self.defaults), + domain.event.FinalizeSubmission(**self.defaults) + ) + + # Announce the submission. + self.paper_id = '1901.00123' + with self.app.app_context(): + session = classic.current_session() + db_row = session.query(classic.models.Submission).first() + db_row.status = classic.models.Submission.ANNOUNCED + dated = (datetime.now() - datetime.utcfromtimestamp(0)) + db_row.document = classic.models.Document( + document_id=1, + paper_id=self.paper_id, + title=self.submission.metadata.title, + authors=self.submission.metadata.authors_display, + dated=dated.total_seconds(), + primary_subject_class=self.category, + created=datetime.now(UTC), + submitter_email=self.submission.creator.email, + submitter_id=self.submission.creator.native_id + ) + db_row.doc_paper_id = self.paper_id + session.add(db_row) + session.commit() + + # Request cross-list classification + category = "cs.IR" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestWithdrawal(reason='A good reason', + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + request_id = self.submission.active_user_requests[0].request_id + self.submission, self.events = save( + domain.event.CancelRequest(request_id=request_id, + **self.defaults), + submission_id=self.submission.submission_id + ) + + def tearDown(self): + """Clear the database after each test.""" + with self.app.app_context(): + classic.drop_all() + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_request_is_cancelled(self): + """Submitter has cancelled the withdrawal request.""" + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 2, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.WITHDRAWAL, + "The second row has type 'wdr'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + + @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) + def test_user_can_make_another_request(self): + """User can now make another request.""" + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestWithdrawal(reason='A better reason', + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + submission, events = load(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + with self.app.app_context(): + submission = load_fast(self.submission.submission_id) + self.assertEqual(submission.status, + domain.submission.Submission.ANNOUNCED, + "The submission is ANNOUNCED.") + + # Check the database state. + with self.app.app_context(): + session = classic.current_session() + db_rows = session.query(classic.models.Submission) \ + .order_by(classic.models.Submission.submission_id.asc()) \ + .all() + + self.assertEqual(len(db_rows), 3, + "There are two rows in the submission table") + self.assertEqual(db_rows[0].type, + classic.models.Submission.NEW_SUBMISSION, + "The first row has type 'new'") + self.assertEqual(db_rows[0].status, + classic.models.Submission.ANNOUNCED, + "The first row is ANNOUNCED") + self.assertEqual(db_rows[1].type, + classic.models.Submission.WITHDRAWAL, + "The second row has type 'wdr'") + self.assertEqual(db_rows[1].status, + classic.models.Submission.USER_DELETED, + "The second row is USER_DELETED") + self.assertEqual(db_rows[2].type, + classic.models.Submission.WITHDRAWAL, + "The third row has type 'wdr'") + self.assertEqual(db_rows[2].status, + classic.models.Submission.PROCESSING_SUBMISSION, + "The third row is PROCESSING_SUBMISSION") + + with self.app.app_context(): + request_id = self.submission.active_user_requests[-1].request_id + self.submission, self.events = save( + domain.event.CancelRequest(request_id=request_id, + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + self.submission, self.events = save( + domain.event.RequestWithdrawal(reason='A better reason', + **self.defaults), + submission_id=self.submission.submission_id + ) + + with self.app.app_context(): + request_id = self.submission.active_user_requests[-1].request_id + self.submission, self.events = save( + domain.event.CancelRequest(request_id=request_id, + **self.defaults), + submission_id=self.submission.submission_id + ) + submission, events = load(self.submission.submission_id) + self.assertEqual(len(submission.active_user_requests), 0) diff --git a/src/arxiv/submission/tests/schedule/__init__.py b/src/arxiv/submission/tests/schedule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/tests/schedule/test_schedule.py b/src/arxiv/submission/tests/schedule/test_schedule.py new file mode 100644 index 0000000..f782980 --- /dev/null +++ b/src/arxiv/submission/tests/schedule/test_schedule.py @@ -0,0 +1,62 @@ +"""Tests for :mod:`.schedule`.""" + +from unittest import TestCase +from datetime import datetime, timedelta +from pytz import timezone, UTC +from ... import schedule + +ET = timezone('US/Eastern') + + +class TestSchedule(TestCase): + """Verify that scheduling functions work as expected.""" + + def test_monday_morning(self): + """E-print was submitted on Monday morning.""" + submitted = ET.localize(datetime(2019, 3, 18, 9, 47, 0)) + self.assertEqual(schedule.next_announcement_time(submitted), + ET.localize(datetime(2019, 3, 18, 20, 0, 0)), + "Will be announced at 8pm this evening") + self.assertEqual(schedule.next_freeze_time(submitted), + ET.localize(datetime(2019, 3, 18, 14, 0, 0)), + "Freeze time is 2pm this afternoon") + + def test_monday_late_afternoon(self): + """E-print was submitted on Monday in the late afternoon.""" + submitted = ET.localize(datetime(2019, 3, 18, 15, 32, 0)) + self.assertEqual(schedule.next_announcement_time(submitted), + ET.localize(datetime(2019, 3, 19, 20, 0, 0)), + "Will be announced at 8pm tomorrow evening") + self.assertEqual(schedule.next_freeze_time(submitted), + ET.localize(datetime(2019, 3, 19, 14, 0, 0)), + "Freeze time is 2pm tomorrow afternoon") + + def test_monday_evening(self): + """E-print was submitted on Monday in the evening.""" + submitted = ET.localize(datetime(2019, 3, 18, 22, 32, 0)) + self.assertEqual(schedule.next_announcement_time(submitted), + ET.localize(datetime(2019, 3, 19, 20, 0, 0)), + "Will be announced at 8pm tomorrow evening") + self.assertEqual(schedule.next_freeze_time(submitted), + ET.localize(datetime(2019, 3, 19, 14, 0, 0)), + "Freeze time is 2pm tomorrow afternoon") + + def test_saturday(self): + """E-print was submitted on a Saturday.""" + submitted = ET.localize(datetime(2019, 3, 23, 22, 32, 0)) + self.assertEqual(schedule.next_announcement_time(submitted), + ET.localize(datetime(2019, 3, 25, 20, 0, 0)), + "Will be announced at 8pm next Monday") + self.assertEqual(schedule.next_freeze_time(submitted), + ET.localize(datetime(2019, 3, 25, 14, 0, 0)), + "Freeze time is 2pm next Monday") + + def test_friday_afternoon(self): + """E-print was submitted on a Friday in the early afternoon.""" + submitted = ET.localize(datetime(2019, 3, 22, 13, 32, 0)) + self.assertEqual(schedule.next_announcement_time(submitted), + ET.localize(datetime(2019, 3, 24, 20, 0, 0)), + "Will be announced at 8pm on Sunday") + self.assertEqual(schedule.next_freeze_time(submitted), + ET.localize(datetime(2019, 3, 22, 14, 0, 0)), + "Freeze time is 2pm that same day") diff --git a/src/arxiv/submission/tests/serializer/__init__.py b/src/arxiv/submission/tests/serializer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/tests/serializer/test_serializer.py b/src/arxiv/submission/tests/serializer/test_serializer.py new file mode 100644 index 0000000..f1c48e3 --- /dev/null +++ b/src/arxiv/submission/tests/serializer/test_serializer.py @@ -0,0 +1,151 @@ +from unittest import TestCase +from datetime import datetime +from pytz import UTC +from dataclasses import asdict +import json + +from ...serializer import dumps, loads +from ...domain.event import CreateSubmission, SetTitle +from ...domain.agent import User, System, Client +from ...domain.submission import Submission, SubmissionContent, License, \ + Classification, CrossListClassificationRequest, Hold, Waiver +from ...domain.proposal import Proposal +from ...domain.process import ProcessStatus +from ...domain.annotation import Feature, Comment +from ...domain.flag import ContentFlag + + +class TestDumpLoad(TestCase): + """Tests for :func:`.dumps` and :func:`.loads`.""" + + def test_dump_createsubmission(self): + """Serialize and deserialize a :class:`.CreateSubmission` event.""" + user = User('123', 'foo@user.com', 'foouser') + event = CreateSubmission(creator=user, created=datetime.now(UTC)) + data = dumps(event) + self.assertDictEqual(asdict(user), json.loads(data)["creator"], + "User data is fully encoded") + deserialized = loads(data) + self.assertEqual(deserialized, event) + self.assertEqual(deserialized.creator, user) + self.assertEqual(deserialized.created, event.created) + + def test_dump_load_submission(self): + """Serialize and deserialize a :class:`.Submission`.""" + user = User('123', 'foo@user.com', 'foouser') + + client = Client('fooclient', 'asdf') + system = System('testprocess') + submission = Submission( + creator=user, + owner=user, + client=client, + created=datetime.now(UTC), + updated=datetime.now(UTC), + submitted=datetime.now(UTC), + source_content=SubmissionContent( + identifier='12345', + checksum='asdf1234', + uncompressed_size=435321, + compressed_size=23421, + source_format=SubmissionContent.Format.TEX + ), + primary_classification=Classification(category='cs.DL'), + secondary_classification=[Classification(category='cs.AI')], + submitter_contact_verified=True, + submitter_is_author=True, + submitter_accepts_policy=True, + submitter_confirmed_preview=True, + license=License('http://foolicense.org/v1', 'The Foo License'), + status=Submission.ANNOUNCED, + arxiv_id='1234.56789', + version=2, + user_requests={ + 'asdf1234': CrossListClassificationRequest('asdf1234', user) + }, + proposals={ + 'prop1234': Proposal( + event_id='prop1234', + creator=user, + proposed_event_type=SetTitle, + proposed_event_data={'title': 'foo title'} + ) + }, + processes=[ + ProcessStatus( + creator=system, + created=datetime.now(UTC), + status=ProcessStatus.Status.SUCCEEDED, + process='FooProcess' + ) + ], + annotations={ + 'asdf123543': Feature( + event_id='asdf123543', + created=datetime.now(UTC), + creator=system, + feature_type=Feature.Type.PAGE_COUNT, + feature_value=12345678.32 + ) + }, + flags={ + 'fooflag1': ContentFlag( + event_id='fooflag1', + creator=system, + created=datetime.now(UTC), + flag_type=ContentFlag.FlagType.LOW_STOP, + flag_data=25, + comment='no comment' + ) + }, + comments={ + 'asdf54321': Comment( + event_id='asdf54321', + creator=system, + created=datetime.now(UTC), + body='here is comment' + ) + }, + holds={ + 'foohold1234': Hold( + event_id='foohold1234', + creator=system, + hold_type=Hold.Type.SOURCE_OVERSIZE, + hold_reason='the best reason' + ) + }, + waivers={ + 'waiver1234': Waiver( + event_id='waiver1234', + waiver_type=Hold.Type.SOURCE_OVERSIZE, + waiver_reason='it is ok', + created=datetime.now(UTC), + creator=system + ) + } + ) + raw = dumps(submission) + loaded = loads(raw) + + self.assertEqual(submission.creator, loaded.creator) + self.assertEqual(submission.owner, loaded.owner) + self.assertEqual(submission.client, loaded.client) + self.assertEqual(submission.created, loaded.created) + self.assertEqual(submission.updated, loaded.updated) + self.assertEqual(submission.submitted, loaded.submitted) + self.assertEqual(submission.source_content, loaded.source_content) + self.assertEqual(submission.source_content.source_format, + loaded.source_content.source_format) + self.assertEqual(submission.primary_classification, + loaded.primary_classification) + self.assertEqual(submission.secondary_classification, + loaded.secondary_classification) + self.assertEqual(submission.license, loaded.license) + self.assertEqual(submission.user_requests, loaded.user_requests) + self.assertEqual(submission.proposals, loaded.proposals) + self.assertEqual(submission.processes, loaded.processes) + self.assertEqual(submission.annotations, loaded.annotations) + self.assertEqual(submission.flags, loaded.flags) + self.assertEqual(submission.comments, loaded.comments) + self.assertEqual(submission.holds, loaded.holds) + self.assertEqual(submission.waivers, loaded.waivers) diff --git a/src/arxiv/submission/tests/util.py b/src/arxiv/submission/tests/util.py new file mode 100644 index 0000000..7a32341 --- /dev/null +++ b/src/arxiv/submission/tests/util.py @@ -0,0 +1,74 @@ +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta +from typing import Optional, List + +from arxiv_auth import domain +from arxiv_auth.auth import tokens +from flask import Flask +from pytz import UTC + +from ..services import classic + + +@contextmanager +def in_memory_db(app: Optional[Flask] = None): + """Provide an in-memory sqlite database for testing purposes.""" + if app is None: + app = Flask('foo') + app.config['CLASSIC_DATABASE_URI'] = 'sqlite://' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + with app.app_context(): + classic.init_app(app) + classic.create_all() + try: + yield classic.current_session() + except Exception: + raise + finally: + classic.drop_all() + + +# Generate authentication token +def generate_token(app: Flask, scope: List[str]) -> str: + """Helper function for generating a JWT.""" + secret = app.config.get('JWT_SECRET') + start = datetime.now(tz=UTC) + end = start + timedelta(seconds=36000) # Make this as long as you want. + user_id = '1' + email = 'foo@bar.com' + username = 'theuser' + first_name = 'Jane' + last_name = 'Doe' + suffix_name = 'IV' + affiliation = 'Cornell University' + rank = 3 + country = 'us' + default_category = 'astro-ph.GA' + submission_groups = 'grp_physics' + endorsements = 'astro-ph.CO,astro-ph.GA' + session = domain.Session( + session_id=str(uuid.uuid4()), + start_time=start, end_time=end, + user=domain.User( + user_id=user_id, + email=email, + username=username, + name=domain.UserFullName(first_name, last_name, suffix_name), + profile=domain.UserProfile( + affiliation=affiliation, + rank=int(rank), + country=country, + default_category=domain.Category(default_category), + submission_groups=submission_groups.split(',') + ) + ), + authorizations=domain.Authorizations( + scopes=scope, + endorsements=[domain.Category(cat.split('.', 1)) + for cat in endorsements.split(',')] + ) + ) + token = tokens.encode(session, secret) + return token \ No newline at end of file From ee806a8305649b2fd1e7eb405a2db1e43364a7ae Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Sun, 15 Sep 2024 17:19:14 -0400 Subject: [PATCH 02/28] initial openapi.yaml schema --- schema/openapi.yaml | 139 +++++++++++++++++++++++++++++++++++++++++ schemas/openapiv1.yaml | 0 2 files changed, 139 insertions(+) create mode 100644 schema/openapi.yaml delete mode 100644 schemas/openapiv1.yaml diff --git a/schema/openapi.yaml b/schema/openapi.yaml new file mode 100644 index 0000000..b0c30c6 --- /dev/null +++ b/schema/openapi.yaml @@ -0,0 +1,139 @@ +openapi: "3.1.0" +info: + version: "0.1" + title: "arxiv submit" + contact: + name: "arXiv API Team" + # TODO need a non NG email + email: nextgen@arxiv.org + license: + name: MIT + +paths: + /: + post: + operationId: new + description: Start a submission and get a submission ID. + responses: + '200': + headers: + Location: + description: URL to use to work with the submission. + content: + description: The submission_id of the submission. + text/plain: + schema: + string + + /{submission_id}: + get: + operationId: getSubmission + description: Get information about a submission. + responses: + '200': + content: + application:json + # TODO add schema + + /{submission_id}/acceptPolicy: + post: + description: | + Agree to a an arXiv policy to initiate a new item submission or + a change to an existing item. + requestBody: + content: + application/json: + schema: + $ref: '#/components/Agreement' + responses: + '200': + description: The has been accepted. + content: + application/json: + schema: + $ref: '#/components/AgreementResponse' + '400': + description: There was an problem when processing the agreement. It was not accepted. + content: + application/json: + schema: + $ref: '#/components/Error' + '401': + description: Unauthorized. Missing valid authentication information. The agreement was not accepted. + '403': + description: Forbidden. Client or user is not authorized to upload. The agreement was not accepted. + '500': + description: Error. There was a problem. The agreement was not accepted. + + + /{submission_id}/markProcessingForDeposit: + post: + description: Mark that the submission is being processed for deposit. + /{submission_id}/unmarkProcessingForDeposit: + post: + description: | + Indicate that an external system in no longer working on depositing this submission. + This does not indicate that is was successfully deposited. + /{submission_id}/deposit_packet: + get: + description: Gets a tar.gz of the current state of the submission. + requestBody: + content: + application/json: + schema: + type: object + properties: + deposit_packet_format: + type: string + enum: + - legacy + - cev1 + + /{submission_id}/Deposited: + post: + description: The submission has been successfully deposited by an external service. + + +################### Server informational #################################### + /status: + get: + operationId: getServiceStatus + description: Get information about the current status of file management service. + responses: + '200': + description: "system is working correctly" + '500': + description: "system is not working correctly" + +############################ Components ##################################### +components: + Agreement: + description: The sender of this request agrees to the statement in the agreement + type: object + required: [submission_id, name, date, agreement ] + properties: + submission_id: + type: string + name: + type: string + agreement: + type: string + + AgreementResponse: + description: | + Information about an agreement. + type: object + required: [ submission_id, name, date, agreement, agreed_at ] + properties: + submission_id: + type: string + agreed_at: + type: datetime + name: + type: string + agreement: + type: string + Error: + type: + text/plain: + schema: string diff --git a/schemas/openapiv1.yaml b/schemas/openapiv1.yaml deleted file mode 100644 index e69de29..0000000 From 26b7b8e46e369ed6795074df98eb9744bc99de89 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Sun, 15 Sep 2024 17:19:44 -0400 Subject: [PATCH 03/28] addes request-toolbelt to pyproject --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 2830957..6fe6c0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1557,6 +1557,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "retry" version = "0.9.2" @@ -1925,4 +1939,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d1de34ca15101cf6e6da14291b3b62d06c6e9af1797d04501bdaca11f8ceb091" +content-hash = "01e370d1c014129615a5f48d5090a48da35780375cdfa02852823953f8f620c5" diff --git a/pyproject.toml b/pyproject.toml index 484135b..fabbf59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ urllib3 = ">=1.24.2" werkzeug = "^2.0" #uwsgi = "==2.0.17.1" semver = "^3.0.2" +requests-toolbelt = "^1.0.0" [tool.poetry.group.dev.dependencies] coverage = "*" From fd7545f2c1bee5dae9827babd4ad3ef8d4f14895 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Sun, 15 Sep 2024 17:49:27 -0400 Subject: [PATCH 04/28] openapi.yaml validates --- schema/openapi.yaml | 165 +++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 47 deletions(-) diff --git a/schema/openapi.yaml b/schema/openapi.yaml index b0c30c6..e040437 100644 --- a/schema/openapi.yaml +++ b/schema/openapi.yaml @@ -1,4 +1,4 @@ -openapi: "3.1.0" +openapi: "3.0.1" info: version: "0.1" title: "arxiv submit" @@ -16,51 +16,70 @@ paths: description: Start a submission and get a submission ID. responses: '200': + description: Successfully started a new submission. headers: Location: description: URL to use to work with the submission. + schema: + type: string content: - description: The submission_id of the submission. text/plain: schema: - string + type: string /{submission_id}: get: operationId: getSubmission description: Get information about a submission. + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string responses: '200': + description: "The submission data." content: - application:json - # TODO add schema + application/json: + schema: + type: object + # TODO add schema /{submission_id}/acceptPolicy: post: description: | Agree to a an arXiv policy to initiate a new item submission or a change to an existing item. + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string requestBody: content: application/json: schema: - $ref: '#/components/Agreement' + $ref: '#/components/schemas/Agreement' responses: - '200': + 200: description: The has been accepted. content: application/json: schema: - $ref: '#/components/AgreementResponse' - '400': + $ref: '#/components/schemas/AgreementResponse' + 400: description: There was an problem when processing the agreement. It was not accepted. content: application/json: schema: - $ref: '#/components/Error' - '401': + $ref: '#/components/schemas/Error' + 401: description: Unauthorized. Missing valid authentication information. The agreement was not accepted. - '403': + 403: description: Forbidden. Client or user is not authorized to upload. The agreement was not accepted. '500': description: Error. There was a problem. The agreement was not accepted. @@ -69,31 +88,84 @@ paths: /{submission_id}/markProcessingForDeposit: post: description: Mark that the submission is being processed for deposit. + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string + responses: + 200: + description: The submission has been marked as in procesing for deposit. + /{submission_id}/unmarkProcessingForDeposit: post: description: | Indicate that an external system in no longer working on depositing this submission. This does not indicate that is was successfully deposited. - /{submission_id}/deposit_packet: + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string + responses: + 200: + description: The submission has been marked as no longer in procesing for deposit. + + /{submission_id}/deposit_packet/{packet_format}: get: description: Gets a tar.gz of the current state of the submission. - requestBody: - content: - application/json: - schema: - type: object - properties: - deposit_packet_format: - type: string - enum: - - legacy - - cev1 + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string + - in: path + name: packet_format + required: true + schema: + type: string + enum: + - legacy + - cev1 + responses: + 200: + description: Returns a tar.gz + headers: + Content-Disposition: + description: Suggests filename + schema: + type: string + content: + application/gzip: + schema: + type: string + format: binary + description: A tar.gz archive with one or more files + + /{submission_id}/Deposited: post: + parameters: + - in: path + name: submission_id + required: true + description: Id of the submission to get. + schema: + type: string description: The submission has been successfully deposited by an external service. + responses: + 200: + description: Deposited has been recorded. - + + ################### Server informational #################################### /status: get: @@ -107,33 +179,32 @@ paths: ############################ Components ##################################### components: - Agreement: - description: The sender of this request agrees to the statement in the agreement - type: object - required: [submission_id, name, date, agreement ] - properties: - submission_id: - type: string - name: - type: string - agreement: - type: string - - AgreementResponse: - description: | - Information about an agreement. + schemas: + Agreement: + description: The sender of this request agrees to the statement in the agreement type: object - required: [ submission_id, name, date, agreement, agreed_at ] + required: [submission_id, name, date, agreement ] properties: submission_id: type: string - agreed_at: - type: datetime name: type: string agreement: type: string - Error: - type: - text/plain: - schema: string + + AgreementResponse: + description: | + Information about an agreement. + type: object + required: [ submission_id, name, date, agreement, agreed_at ] + properties: + submission_id: + type: string + agreed_at: + type: datetime + name: + type: string + agreement: + type: string + Error: + type: string From cdff56bc411867bebb9a98fa2cd8637bfb2e46cc Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Mon, 16 Sep 2024 13:24:44 -0400 Subject: [PATCH 05/28] Working fastapi with some stubs, passing stub tests, updated README --- .flake8 | 3 + Dockerfile | 30 + README.md | 30 +- poetry.lock | 766 ++++++++++++++---- pyproject.toml | 46 +- src/arxiv/__init__.py | 0 src/arxiv/submission/domain/agent.py | 2 +- src/arxiv/submit_fastapi/__init__.py | 0 src/arxiv/submit_fastapi/app.py | 10 + src/arxiv/submit_fastapi/default_api.py | 150 ++++ src/arxiv/submit_fastapi/main.py | 13 + src/arxiv/submit_fastapi/models/__init__.py | 0 src/arxiv/submit_fastapi/models/agreement.py | 95 +++ .../submit_fastapi/models/extra_models.py | 8 + tests/conftest.py | 17 + tests/test_default_api.py | 161 ++++ 16 files changed, 1156 insertions(+), 175 deletions(-) create mode 100644 .flake8 create mode 100644 Dockerfile create mode 100644 src/arxiv/__init__.py create mode 100644 src/arxiv/submit_fastapi/__init__.py create mode 100644 src/arxiv/submit_fastapi/app.py create mode 100644 src/arxiv/submit_fastapi/default_api.py create mode 100644 src/arxiv/submit_fastapi/main.py create mode 100644 src/arxiv/submit_fastapi/models/__init__.py create mode 100644 src/arxiv/submit_fastapi/models/agreement.py create mode 100644 src/arxiv/submit_fastapi/models/extra_models.py create mode 100644 tests/conftest.py create mode 100644 tests/test_default_api.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9e008c5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache,.venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c96d215 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11 AS builder + +WORKDIR /usr/app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +RUN pip install --upgrade pip + +COPY . . +RUN pip install --no-cache-dir . + + +#FROM python:3.7 AS test_runner +#WORKDIR /tmp +#COPY --from=builder /venv /venv +#COPY --from=builder /usr/app/tests tests +#ENV PATH=/venv/bin:$PATH +# +## install test dependencies +#RUN pip install pytest +# +## run tests +#RUN pytest tests + + +FROM python:3.11 AS service +WORKDIR /root/app/site-packages +COPY --from=builder /venv /venv +ENV PATH=/venv/bin:$PATH diff --git a/README.md b/README.md index 570746a..aaf8016 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ -# submit-ce +# submit-ce API arXiv paper submission system + +## Installation & Usage + +To run the server, please execute the following from the root directory: + +```bash +poetry install +submit_fastapi dev src/arxiv/submit_fastapi/main.py +``` + +and open your browser at `http://localhost:8000/docs/` to see the docs. + +## Running with Docker + +To run the server on a Docker container, please execute the following from the root directory: + +```bash +docker-compose up --build +``` + +## Tests + +To run the tests: + +```bash +pip3 install pytest +PYTHONPATH=src pytest tests +``` diff --git a/poetry.lock b/poetry.lock index 6fe6c0d..a9199b3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,60 +1,48 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -[[package]] -name = "arxiv-auth" -version = "1.1.0" -description = "Auth libraries for arXiv." -optional = false -python-versions = "^3.10" -files = [] -develop = false - -[package.dependencies] -arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", rev = "1.0.1"} -flask = "*" -flask-sqlalchemy = "*" -mysqlclient = "*" -pydantic = "^1.0" -pyjwt = "*" -python-dateutil = "*" -redis = "==2.10.6" -redis-py-cluster = "==1.3.6" -sqlalchemy = "*" - -[package.source] -type = "git" -url = "https://github.com/arXiv/arxiv-auth.git" -reference = "develop" -resolved_reference = "241169e13aa74b2fad57a8ba05ec3305ccff5ea0" -subdirectory = "arxiv-auth" - [[package]] name = "arxiv-base" -version = "1.0.0a5" -description = "Common code for arXiv NG" +version = "1.0.1" +description = "Common code for arXiv Cloud" optional = false -python-versions = "^3.10" +python-versions = "^3.11" files = [] develop = false [package.dependencies] bleach = "*" -boto3 = "==1.*" -flask = "~=2.2" +fastly = "*" +fire = "^0.5.0" +flask = "3.0.*" flask-s3 = "*" +google-auth = "^2.23.4" +google-cloud-logging = "^3.8.0" +google-cloud-monitoring = "^2.16.0" +google-cloud-pubsub = "^2.18.4" google-cloud-storage = "^2.5.0" markupsafe = "*" +mimesis = "*" +mysqlclient = ">=2.1" +pydantic = "==1.10.*" +pyjwt = "*" pytz = "*" -retry = "*" -semantic-version = "*" +redis = "==2.10.6" +redis-py-cluster = "==1.3.6" +retry = "^0.9.2" +setuptools = "^70.0.0" +sqlalchemy = "~=2.0.27" typing-extensions = "*" +validators = "*" wtforms = "*" +[package.extras] +sphinx = ["sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-websupport"] + [package.source] type = "git" url = "https://github.com/arXiv/arxiv-base.git" -reference = "1.0.1" -resolved_reference = "45c557e75bc6b645dded63811770b87c405bda3e" +reference = "develop" +resolved_reference = "25952ff7b63ca3a36ac8f947137f16b44c6c8fce" [[package]] name = "astroid" @@ -72,6 +60,25 @@ lazy-object-proxy = "*" six = "*" wrapt = "*" +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "backports-datetime-fromisoformat" version = "2.0.2" @@ -159,17 +166,17 @@ files = [ [[package]] name = "boto3" -version = "1.35.18" +version = "1.35.19" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.18-py3-none-any.whl", hash = "sha256:71e237d3997cf93425947854d7b121c577944f391ba633afb0659e1015364704"}, - {file = "boto3-1.35.18.tar.gz", hash = "sha256:fd130308f1f49d748a5fc63de92de79a995b51c79af3947ddde8815fcf0684fe"}, + {file = "boto3-1.35.19-py3-none-any.whl", hash = "sha256:84b3fe1727945bc3cada832d969ddb3dc0d08fce1677064ca8bdc13a89c1a143"}, + {file = "boto3-1.35.19.tar.gz", hash = "sha256:9979fe674780a0b7100eae9156d74ee374cd1638a9f61c77277e3ce712f3e496"}, ] [package.dependencies] -botocore = ">=1.35.18,<1.36.0" +botocore = ">=1.35.19,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -178,13 +185,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.18" +version = "1.35.19" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.18-py3-none-any.whl", hash = "sha256:1027083aeb1fe74057273410fd768e018e22f85adfbd717b5a69f578f7812b80"}, - {file = "botocore-1.35.18.tar.gz", hash = "sha256:e59da8b91ab06683d2725b6cbbb0383b30c68a241c3c63363f4c5bff59b3c0c0"}, + {file = "botocore-1.35.19-py3-none-any.whl", hash = "sha256:c83f7f0cacfe7c19b109b363ebfa8736e570d24922f16ed371681f58ebab44a9"}, + {file = "botocore-1.35.19.tar.gz", hash = "sha256:42d6d8db7250cbd7899f786f9861e02cab17dc238f64d6acb976098ed9809625"}, ] [package.dependencies] @@ -422,9 +429,6 @@ files = [ {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli"] @@ -458,6 +462,23 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + [[package]] name = "docker" version = "7.1.0" @@ -491,28 +512,43 @@ files = [ ] [[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" +name = "fastly" +version = "5.9.0" +description = "A Python Fastly API client library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "fastly-5.9.0-py3-none-any.whl", hash = "sha256:ce5c20e59694bc77723322bf62b293fc89980aa1fc164f6f9b8703d10f703768"}, + {file = "fastly-5.9.0.tar.gz", hash = "sha256:a52e1baf0bbf3efab1e93e76321cf8288a9d386e7e5d9d018753e50e4f62fef6"}, ] -[package.extras] -test = ["pytest (>=6)"] +[package.dependencies] +python-dateutil = "*" +urllib3 = ">=1.25.3" + +[[package]] +name = "fire" +version = "0.5.0" +description = "A library for automatically generating command line interfaces." +optional = false +python-versions = "*" +files = [ + {file = "fire-0.5.0.tar.gz", hash = "sha256:a6b0d49e98c8963910021f92bba66f65ab440da2982b78eb1bbf95a0a34aacc6"}, +] + +[package.dependencies] +six = "*" +termcolor = "*" [[package]] name = "flask" -version = "2.3.3" +version = "3.0.3" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.8" files = [ - {file = "flask-2.3.3-py3-none-any.whl", hash = "sha256:f69fcd559dc907ed196ab9df0e48471709175e696d6e698dd4dbe940f96ce66b"}, - {file = "flask-2.3.3.tar.gz", hash = "sha256:09c347a92aa7ff4a8e7f3206795f30d826654baf38b873d0744cd571ca609efc"}, + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, ] [package.dependencies] @@ -520,7 +556,7 @@ blinker = ">=1.6.2" click = ">=8.1.3" itsdangerous = ">=2.1.2" Jinja2 = ">=3.1.2" -Werkzeug = ">=2.3.7" +Werkzeug = ">=3.0.0" [package.extras] async = ["asgiref (>=3.2)"] @@ -542,21 +578,6 @@ Boto3 = ">=1.1.1" Flask = "*" six = "*" -[[package]] -name = "flask-sqlalchemy" -version = "3.1.1" -description = "Add SQLAlchemy support to your Flask application." -optional = false -python-versions = ">=3.8" -files = [ - {file = "flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"}, - {file = "flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312"}, -] - -[package.dependencies] -flask = ">=2.2.5" -sqlalchemy = ">=2.0.16" - [[package]] name = "google-api-core" version = "2.19.2" @@ -571,6 +592,8 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" +grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = ">=1.22.3,<2.0.0dev" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" requests = ">=2.18.0,<3.0.0.dev0" @@ -603,6 +626,38 @@ pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +[[package]] +name = "google-cloud-appengine-logging" +version = "1.4.5" +description = "Google Cloud Appengine Logging API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_appengine_logging-1.4.5-py2.py3-none-any.whl", hash = "sha256:344e0244404049b42164e4d6dc718ca2c81b393d066956e7cb85fd9407ed9c48"}, + {file = "google_cloud_appengine_logging-1.4.5.tar.gz", hash = "sha256:de7d766e5d67b19fc5833974b505b32d2a5bbdfb283fd941e320e7cfdae4cb83"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[[package]] +name = "google-cloud-audit-log" +version = "0.3.0" +description = "Google Cloud Audit Protos" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_audit_log-0.3.0-py2.py3-none-any.whl", hash = "sha256:8340793120a1d5aa143605def8704ecdcead15106f754ef1381ae3bab533722f"}, + {file = "google_cloud_audit_log-0.3.0.tar.gz", hash = "sha256:901428b257020d8c1d1133e0fa004164a555e5a395c7ca3cdbb8486513df3a65"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.56.2,<2.0dev" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + [[package]] name = "google-cloud-core" version = "2.4.1" @@ -621,6 +676,71 @@ google-auth = ">=1.25.0,<3.0dev" [package.extras] grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +[[package]] +name = "google-cloud-logging" +version = "3.11.2" +description = "Stackdriver Logging API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_logging-3.11.2-py2.py3-none-any.whl", hash = "sha256:0a755f04f184fbe77ad608258dc283a032485ebb4d0e2b2501964059ee9c898f"}, + {file = "google_cloud_logging-3.11.2.tar.gz", hash = "sha256:4897441c2b74f6eda9181c23a8817223b6145943314a821d64b729d30766cb2b"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-cloud-appengine-logging = ">=0.1.3,<2.0.0dev" +google-cloud-audit-log = ">=0.2.4,<1.0.0dev" +google-cloud-core = ">=2.0.0,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +opentelemetry-api = ">=1.9.0" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[[package]] +name = "google-cloud-monitoring" +version = "2.22.2" +description = "Google Cloud Monitoring API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_monitoring-2.22.2-py2.py3-none-any.whl", hash = "sha256:3f07aead6a80a894c5f8e151f1cccf78478eab14e14294f4b83aaa3f478b5c4e"}, + {file = "google_cloud_monitoring-2.22.2.tar.gz", hash = "sha256:9fc22dac48d14dd1c7fb83ee4a54f7a57bf9852ee6b5c9dca9e3bb093b04c7ee"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[package.extras] +pandas = ["pandas (>=0.23.2)"] + +[[package]] +name = "google-cloud-pubsub" +version = "2.23.1" +description = "Google Cloud Pub/Sub API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_pubsub-2.23.1-py2.py3-none-any.whl", hash = "sha256:a173292a699851eb622016d3f2796ecf2d69692e708ea0e7382f338fc1679f8a"}, + {file = "google_cloud_pubsub-2.23.1.tar.gz", hash = "sha256:e1fde79b5b64b721290af4c022907afcbb83512d92f4e5c334c391cfbb022acb"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +grpcio = ">=1.51.3,<2.0dev" +grpcio-status = ">=1.33.2" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[package.extras] +libcst = ["libcst (>=0.3.10)"] + [[package]] name = "google-cloud-storage" version = "2.18.2" @@ -713,6 +833,7 @@ files = [ ] [package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0.dev0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" [package.extras] @@ -797,17 +918,129 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] +[[package]] +name = "grpc-google-iam-v1" +version = "0.13.1" +description = "IAM API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpc-google-iam-v1-0.13.1.tar.gz", hash = "sha256:3ff4b2fd9d990965e410965253c0da6f66205d5a8291c4c31c6ebecca18a9001"}, + {file = "grpc_google_iam_v1-0.13.1-py2.py3-none-any.whl", hash = "sha256:c3e86151a981811f30d5e7330f271cee53e73bb87755e88cc3b6f0c7b5fe374e"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0dev" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[[package]] +name = "grpcio" +version = "1.66.1" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "grpcio-1.66.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:4877ba180591acdf127afe21ec1c7ff8a5ecf0fe2600f0d3c50e8c4a1cbc6492"}, + {file = "grpcio-1.66.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:3750c5a00bd644c75f4507f77a804d0189d97a107eb1481945a0cf3af3e7a5ac"}, + {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:a013c5fbb12bfb5f927444b477a26f1080755a931d5d362e6a9a720ca7dbae60"}, + {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1b24c23d51a1e8790b25514157d43f0a4dce1ac12b3f0b8e9f66a5e2c4c132f"}, + {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7ffb8ea674d68de4cac6f57d2498fef477cef582f1fa849e9f844863af50083"}, + {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:307b1d538140f19ccbd3aed7a93d8f71103c5d525f3c96f8616111614b14bf2a"}, + {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c17ebcec157cfb8dd445890a03e20caf6209a5bd4ac5b040ae9dbc59eef091d"}, + {file = "grpcio-1.66.1-cp310-cp310-win32.whl", hash = "sha256:ef82d361ed5849d34cf09105d00b94b6728d289d6b9235513cb2fcc79f7c432c"}, + {file = "grpcio-1.66.1-cp310-cp310-win_amd64.whl", hash = "sha256:292a846b92cdcd40ecca46e694997dd6b9be6c4c01a94a0dfb3fcb75d20da858"}, + {file = "grpcio-1.66.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:c30aeceeaff11cd5ddbc348f37c58bcb96da8d5aa93fed78ab329de5f37a0d7a"}, + {file = "grpcio-1.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8a1e224ce6f740dbb6b24c58f885422deebd7eb724aff0671a847f8951857c26"}, + {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a66fe4dc35d2330c185cfbb42959f57ad36f257e0cc4557d11d9f0a3f14311df"}, + {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ba04659e4fce609de2658fe4dbf7d6ed21987a94460f5f92df7579fd5d0e22"}, + {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4573608e23f7e091acfbe3e84ac2045680b69751d8d67685ffa193a4429fedb1"}, + {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7e06aa1f764ec8265b19d8f00140b8c4b6ca179a6dc67aa9413867c47e1fb04e"}, + {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3885f037eb11f1cacc41f207b705f38a44b69478086f40608959bf5ad85826dd"}, + {file = "grpcio-1.66.1-cp311-cp311-win32.whl", hash = "sha256:97ae7edd3f3f91480e48ede5d3e7d431ad6005bfdbd65c1b56913799ec79e791"}, + {file = "grpcio-1.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:cfd349de4158d797db2bd82d2020554a121674e98fbe6b15328456b3bf2495bb"}, + {file = "grpcio-1.66.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:a92c4f58c01c77205df6ff999faa008540475c39b835277fb8883b11cada127a"}, + {file = "grpcio-1.66.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fdb14bad0835914f325349ed34a51940bc2ad965142eb3090081593c6e347be9"}, + {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f03a5884c56256e08fd9e262e11b5cfacf1af96e2ce78dc095d2c41ccae2c80d"}, + {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2559692d8e7e245d456877a85ee41525f3ed425aa97eb7a70fc9a79df91a0"}, + {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca1be089fb4446490dd1135828bd42a7c7f8421e74fa581611f7afdf7ab761"}, + {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d639c939ad7c440c7b2819a28d559179a4508783f7e5b991166f8d7a34b52815"}, + {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b9feb4e5ec8dc2d15709f4d5fc367794d69277f5d680baf1910fc9915c633524"}, + {file = "grpcio-1.66.1-cp312-cp312-win32.whl", hash = "sha256:7101db1bd4cd9b880294dec41a93fcdce465bdbb602cd8dc5bd2d6362b618759"}, + {file = "grpcio-1.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:b0aa03d240b5539648d996cc60438f128c7f46050989e35b25f5c18286c86734"}, + {file = "grpcio-1.66.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:ecfe735e7a59e5a98208447293ff8580e9db1e890e232b8b292dc8bd15afc0d2"}, + {file = "grpcio-1.66.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4825a3aa5648010842e1c9d35a082187746aa0cdbf1b7a2a930595a94fb10fce"}, + {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f517fd7259fe823ef3bd21e508b653d5492e706e9f0ef82c16ce3347a8a5620c"}, + {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1fe60d0772831d96d263b53d83fb9a3d050a94b0e94b6d004a5ad111faa5b5b"}, + {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31a049daa428f928f21090403e5d18ea02670e3d5d172581670be006100db9ef"}, + {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f914386e52cbdeb5d2a7ce3bf1fdfacbe9d818dd81b6099a05b741aaf3848bb"}, + {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bff2096bdba686019fb32d2dde45b95981f0d1490e054400f70fc9a8af34b49d"}, + {file = "grpcio-1.66.1-cp38-cp38-win32.whl", hash = "sha256:aa8ba945c96e73de29d25331b26f3e416e0c0f621e984a3ebdb2d0d0b596a3b3"}, + {file = "grpcio-1.66.1-cp38-cp38-win_amd64.whl", hash = "sha256:161d5c535c2bdf61b95080e7f0f017a1dfcb812bf54093e71e5562b16225b4ce"}, + {file = "grpcio-1.66.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:d0cd7050397b3609ea51727b1811e663ffda8bda39c6a5bb69525ef12414b503"}, + {file = "grpcio-1.66.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0e6c9b42ded5d02b6b1fea3a25f036a2236eeb75d0579bfd43c0018c88bf0a3e"}, + {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:c9f80f9fad93a8cf71c7f161778ba47fd730d13a343a46258065c4deb4b550c0"}, + {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dd67ed9da78e5121efc5c510f0122a972216808d6de70953a740560c572eb44"}, + {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b0d92d45ce3be2084b92fb5bae2f64c208fea8ceed7fccf6a7b524d3c4942e"}, + {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d813316d1a752be6f5c4360c49f55b06d4fe212d7df03253dfdae90c8a402bb"}, + {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9c9bebc6627873ec27a70fc800f6083a13c70b23a5564788754b9ee52c5aef6c"}, + {file = "grpcio-1.66.1-cp39-cp39-win32.whl", hash = "sha256:30a1c2cf9390c894c90bbc70147f2372130ad189cffef161f0432d0157973f45"}, + {file = "grpcio-1.66.1-cp39-cp39-win_amd64.whl", hash = "sha256:17663598aadbedc3cacd7bbde432f541c8e07d2496564e22b214b22c7523dac8"}, + {file = "grpcio-1.66.1.tar.gz", hash = "sha256:35334f9c9745add3e357e3372756fd32d925bd52c41da97f4dfdafbde0bf0ee2"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.66.1)"] + +[[package]] +name = "grpcio-status" +version = "1.66.1" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.8" +files = [ + {file = "grpcio_status-1.66.1-py3-none-any.whl", hash = "sha256:cf9ed0b4a83adbe9297211c95cb5488b0cd065707e812145b842c85c4782ff02"}, + {file = "grpcio_status-1.66.1.tar.gz", hash = "sha256:b3f7d34ccc46d83fea5261eea3786174459f763c31f6e34f1d24eba6d515d024"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.66.1" +protobuf = ">=5.26.1,<6.0dev" + [[package]] name = "idna" -version = "3.8" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" files = [ - {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, - {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.4.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -819,20 +1052,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "isodate" -version = "0.6.1" -description = "An ISO 8601 date/time/duration parser and formatter" -optional = false -python-versions = "*" -files = [ - {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, - {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, -] - -[package.dependencies] -six = "*" - [[package]] name = "isort" version = "5.13.2" @@ -888,17 +1107,55 @@ files = [ [[package]] name = "jsonschema" -version = "2.6.0" +version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "jsonschema-2.6.0-py2.py3-none-any.whl", hash = "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08"}, - {file = "jsonschema-2.6.0.tar.gz", hash = "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02"}, + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, ] +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + [package.extras] -format = ["rfc3987", "strict-rfc3339", "webcolors"] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-path" +version = "0.3.3" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.8.0" +files = [ + {file = "jsonschema_path-0.3.3-py3-none-any.whl", hash = "sha256:203aff257f8038cd3c67be614fe6b2001043408cb1b4e36576bc4921e09d83c4"}, + {file = "jsonschema_path-0.3.3.tar.gz", hash = "sha256:f02e5481a4288ec062f8e68c808569e427d905bedfecb7f2e4c69ef77957c382"}, +] + +[package.dependencies] +pathable = ">=0.4.1,<0.5.0" +PyYAML = ">=5.1" +referencing = ">=0.28.0,<0.36.0" +requests = ">=2.31.0,<3.0.0" + +[[package]] +name = "jsonschema-specifications" +version = "2023.12.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, + {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, +] + +[package.dependencies] +referencing = ">=0.31.0" [[package]] name = "lazy-object-proxy" @@ -1028,15 +1285,19 @@ files = [ [[package]] name = "mimesis" -version = "2.1.0" -description = "Mimesis: mock data for developers." +version = "18.0.0" +description = "Mimesis: Fake Data Generator." optional = false -python-versions = "*" +python-versions = "<4.0,>=3.10" files = [ - {file = "mimesis-2.1.0-py3-none-any.whl", hash = "sha256:2a17aa98cc8aff2c0b1828312e213a515030ed57a1c7b61fc07a87150cb0f25f"}, - {file = "mimesis-2.1.0.tar.gz", hash = "sha256:4b856023acdaaefe2e10bbfea9fd4cb6fa9adbbbe9618a8f796aa8887b58e6f2"}, + {file = "mimesis-18.0.0-py3-none-any.whl", hash = "sha256:a51854a5ce63ebf2bd6a98e8841412e04cede38593be7e16d1d712848e6273df"}, + {file = "mimesis-18.0.0.tar.gz", hash = "sha256:7d7c76ecd680ae48afe8dc4413ef1ef1ee7ef20e16f9f9cb42892add642fc1b2"}, ] +[package.extras] +factory = ["factory-boy (>=3.3.0,<4.0.0)"] +pytest = ["pytest (>=7.2,<8.0)"] + [[package]] name = "mypy" version = "1.11.2" @@ -1075,7 +1336,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.6.0" [package.extras] @@ -1115,43 +1375,51 @@ files = [ [[package]] name = "openapi-schema-validator" -version = "0.1.2" +version = "0.6.2" description = "OpenAPI schema validation for Python" optional = false -python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*" +python-versions = ">=3.8.0,<4.0.0" files = [ - {file = "openapi-schema-validator-0.1.2.tar.gz", hash = "sha256:c1596cae94f0319a68e331e823ca1adf763b1823841e8b6b03d09ea486e44e76"}, - {file = "openapi_schema_validator-0.1.2-py2-none-any.whl", hash = "sha256:ba27b42454d97d0d46151172c2d70b3027464bdd720060c1e8ebb4b29a255e6d"}, - {file = "openapi_schema_validator-0.1.2-py3-none-any.whl", hash = "sha256:4b32307ccd048c82a447088ba72a9f00e1a8607650096f0839a6ca76eecb16c5"}, + {file = "openapi_schema_validator-0.6.2-py3-none-any.whl", hash = "sha256:c4887c1347c669eb7cded9090f4438b710845cd0f90d1fb9e1b3303fb37339f8"}, + {file = "openapi_schema_validator-0.6.2.tar.gz", hash = "sha256:11a95c9c9017912964e3e5f2545a5b11c3814880681fcacfb73b1759bb4f2804"}, ] [package.dependencies] -isodate = "*" -jsonschema = "*" -six = "*" -strict-rfc3339 = "*" +jsonschema = ">=4.19.1,<5.0.0" +jsonschema-specifications = ">=2023.5.2,<2024.0.0" +rfc3339-validator = "*" [[package]] name = "openapi-spec-validator" -version = "0.3.1" -description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3.0 spec validator" +version = "0.7.1" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" optional = false -python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*" +python-versions = ">=3.8.0,<4.0.0" files = [ - {file = "openapi-spec-validator-0.3.1.tar.gz", hash = "sha256:3d70e6592754799f7e77a45b98c6a91706bdd309a425169d17d8e92173e198a2"}, - {file = "openapi_spec_validator-0.3.1-py2-none-any.whl", hash = "sha256:0a7da925bad4576f4518f77302c0b1990adb2fbcbe7d63fb4ed0de894cad8bdd"}, - {file = "openapi_spec_validator-0.3.1-py3-none-any.whl", hash = "sha256:ba28b06e63274f2bc6de995a07fb572c657e534425b5baf68d9f7911efe6929f"}, + {file = "openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959"}, + {file = "openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7"}, ] [package.dependencies] -jsonschema = "*" -openapi-schema-validator = "*" -PyYAML = ">=5.1" -six = "*" +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" +lazy-object-proxy = ">=1.7.1,<2.0.0" +openapi-schema-validator = ">=0.6.0,<0.7.0" -[package.extras] -dev = ["pre-commit"] -requests = ["requests"] +[[package]] +name = "opentelemetry-api" +version = "1.27.0" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7"}, + {file = "opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +importlib-metadata = ">=6.0,<=8.4.0" [[package]] name = "packaging" @@ -1164,6 +1432,17 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathable" +version = "0.4.3" +description = "Object-oriented paths" +optional = false +python-versions = ">=3.7.0,<4.0.0" +files = [ + {file = "pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14"}, + {file = "pathable-0.4.3.tar.gz", hash = "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab"}, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -1375,11 +1654,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1536,6 +1813,21 @@ files = [ [package.dependencies] redis = "2.10.6" +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + [[package]] name = "requests" version = "2.32.3" @@ -1586,6 +1878,132 @@ files = [ decorator = ">=3.4.2" py = ">=1.4.26,<2.0.0" +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rpds-py" +version = "0.20.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, + {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, + {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, + {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, + {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, + {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, + {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, + {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, + {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, + {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, + {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, + {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, + {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, + {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, + {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, +] + [[package]] name = "rsa" version = "4.9" @@ -1617,21 +2035,6 @@ botocore = ">=1.33.2,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] -[[package]] -name = "semantic-version" -version = "2.10.0" -description = "A library implementing the 'SemVer' scheme." -optional = false -python-versions = ">=2.7" -files = [ - {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, - {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, -] - -[package.extras] -dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] -doc = ["Sphinx", "sphinx-rtd-theme"] - [[package]] name = "semver" version = "3.0.2" @@ -1643,6 +2046,21 @@ files = [ {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, ] +[[package]] +name = "setuptools" +version = "70.3.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -1753,25 +2171,18 @@ pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] [[package]] -name = "strict-rfc3339" -version = "0.7" -description = "Strict, simple, lightweight RFC3339 functions" +name = "termcolor" +version = "2.4.0" +description = "ANSI color formatting for output in terminal" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, + {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, + {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, ] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] +[package.extras] +tests = ["pytest", "pytest-cov"] [[package]] name = "typing-extensions" @@ -1812,6 +2223,20 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "validators" +version = "0.34.0" +description = "Python Data Validation for Humans™" +optional = false +python-versions = ">=3.8" +files = [ + {file = "validators-0.34.0-py3-none-any.whl", hash = "sha256:c804b476e3e6d3786fa07a30073a4ef694e617805eb1946ceee3fe5a9b8b1321"}, + {file = "validators-0.34.0.tar.gz", hash = "sha256:647fe407b45af9a74d245b943b18e6a816acf4926974278f6dd617778e1e781f"}, +] + +[package.extras] +crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] + [[package]] name = "webencodings" version = "0.5.1" @@ -1825,13 +2250,13 @@ files = [ [[package]] name = "werkzeug" -version = "2.3.8" +version = "3.0.4" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.8" files = [ - {file = "werkzeug-2.3.8-py3-none-any.whl", hash = "sha256:bba1f19f8ec89d4d607a3bd62f1904bd2e609472d93cd85e9d4e178f472c3748"}, - {file = "werkzeug-2.3.8.tar.gz", hash = "sha256:554b257c74bbeb7a0d254160a4f8ffe185243f52a52035060b761ca62d977f03"}, + {file = "werkzeug-3.0.4-py3-none-any.whl", hash = "sha256:02c9eb92b7d6c06f31a782811505d2157837cea66aaede3e217c7c27c039476c"}, + {file = "werkzeug-3.0.4.tar.gz", hash = "sha256:34f2371506b250df4d4f84bfe7b0921e4762525762bbd936614909fe25cd7306"}, ] [package.dependencies] @@ -1936,7 +2361,26 @@ markupsafe = "*" [package.extras] email = ["email-validator"] +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [metadata] lock-version = "2.0" -python-versions = "^3.10" -content-hash = "01e370d1c014129615a5f48d5090a48da35780375cdfa02852823953f8f620c5" +python-versions = "^3.11" +content-hash = "15affb779de0b0588c2c7d85bdb8348b6ffffa3279cd7f7e91cc2844ca856f04" diff --git a/pyproject.toml b/pyproject.toml index fabbf59..763d7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,18 +15,12 @@ readme = "README.md" packages = [{include = "arxiv", from="src"}] [tool.poetry.dependencies] -python = "^3.10" - -#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", rev = "6db6e9fc", extras = []} -#arxiv-base = "~=0.16.6" - -arxiv-auth = {git = "https://github.com/arXiv/arxiv-auth.git", rev = "develop", subdirectory = "arxiv-auth"} -#arxiv-auth = "~=0.4.2rc1" +python = "^3.11" +arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", branch = "develop" } backports-datetime-fromisoformat = "*" -flask-sqlalchemy = "*" -jsonschema = "==2.6.0" -mimesis = "==2.1.0" +jsonschema = "*" +mimesis = "*" mypy = "*" mypy_extensions = "*" @@ -36,8 +30,6 @@ pyyaml = ">=4.2b1" retry = "*" unidecode = "*" urllib3 = ">=1.24.2" -werkzeug = "^2.0" -#uwsgi = "==2.0.17.1" semver = "^3.0.2" requests-toolbelt = "^1.0.0" @@ -52,6 +44,36 @@ pylint = "<2" pytest = "*" pytest-cov = "*" +[tool.poetry.scripts] +gen_server = "arxiv.scripts:gen_server" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +exclude = ''' +( + /( + \.eggs # exclude a few common directories in the + | \.git # root of the project + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + )/ +) +''' + +[tool.isort] +profile = "black" +skip = [ + '.eggs', '.git', '.hg', '.mypy_cache', '.nox', '.pants.d', '.tox', + '.venv', '_build', 'buck-out', 'build', 'dist', 'node_modules', 'venv', +] +skip_gitignore = true diff --git a/src/arxiv/__init__.py b/src/arxiv/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submission/domain/agent.py b/src/arxiv/submission/domain/agent.py index 922429d..fd2494d 100644 --- a/src/arxiv/submission/domain/agent.py +++ b/src/arxiv/submission/domain/agent.py @@ -60,7 +60,7 @@ def __eq__(self, other: Any) -> bool: @dataclass class User(Agent): - """An (human) end user.""" + """A human end user.""" forename: str = field(default_factory=str) surname: str = field(default_factory=str) diff --git a/src/arxiv/submit_fastapi/__init__.py b/src/arxiv/submit_fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submit_fastapi/app.py b/src/arxiv/submit_fastapi/app.py new file mode 100644 index 0000000..509f1e3 --- /dev/null +++ b/src/arxiv/submit_fastapi/app.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI +from .default_api import router as DefaultApiRouter + +app = FastAPI( + title="arxiv submit", + description="No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)", + version="0.1", +) + +app.include_router(DefaultApiRouter) diff --git a/src/arxiv/submit_fastapi/default_api.py b/src/arxiv/submit_fastapi/default_api.py new file mode 100644 index 0000000..75c9c16 --- /dev/null +++ b/src/arxiv/submit_fastapi/default_api.py @@ -0,0 +1,150 @@ +# coding: utf-8 + +from typing import Dict, List # noqa: F401 +import importlib +import pkgutil + +from fastapi import ( # noqa: F401 + APIRouter, + Body, + Cookie, + Depends, + Form, + Header, + HTTPException, + Path, + Query, + Response, + Security, + status, +) + +from .models.extra_models import TokenModel # noqa: F401 +from .models.agreement import Agreement + + +router = APIRouter() + +BaseDefaultApi = None + +@router.get( + "/status", + responses={ + 200: {"description": "system is working correctly"}, + 500: {"description": "system is not working correctly"}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def get_service_status( +) -> None: + """Get information about the current status of file management service.""" + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().get_service_status() + + +@router.get( + "/{submission_id}", + responses={ + 200: {"model": object, "description": "The submission data."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def get_submission( + submission_id: str = Path(..., description="Id of the submission to get."), +) -> object: + """Get information about a submission.""" + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().get_submission(submission_id) + + +@router.post( + "/", + responses={ + 200: {"model": str, "description": "Successfully started a new submission."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def new( +) -> str: + """Start a submission and get a submission ID.""" + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().new() + + +@router.post( + "/{submission_id}/acceptPolicy", + responses={ + 200: {"model": object, "description": "The has been accepted."}, + 400: {"model": str, "description": "There was an problem when processing the agreement. It was not accepted."}, + 401: {"description": "Unauthorized. Missing valid authentication information. The agreement was not accepted."}, + 403: {"description": "Forbidden. Client or user is not authorized to upload. The agreement was not accepted."}, + 500: {"description": "Error. There was a problem. The agreement was not accepted."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def submission_id_accept_policy_post( + submission_id: str = Path(..., description="Id of the submission to get."), + agreement: Agreement = Body(None, description=""), +) -> object: + """Agree to a an arXiv policy to initiate a new item submission or a change to an existing item. """ + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().submission_id_accept_policy_post(submission_id, agreement) + + +@router.post( + "/{submission_id}/Deposited", + responses={ + 200: {"description": "Deposited has been recorded."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def submission_id_deposited_post( + submission_id: str = Path(..., description="Id of the submission to get."), +) -> None: + """The submission has been successfully deposited by an external service.""" + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().submission_id_deposited_post(submission_id) + + +@router.post( + "/{submission_id}/markProcessingForDeposit", + responses={ + 200: {"description": "The submission has been marked as in procesing for deposit."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def submission_id_mark_processing_for_deposit_post( + submission_id: str = Path(..., description="Id of the submission to get."), +) -> None: + """Mark that the submission is being processed for deposit.""" + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().submission_id_mark_processing_for_deposit_post(submission_id) + + +@router.post( + "/{submission_id}/unmarkProcessingForDeposit", + responses={ + 200: {"description": "The submission has been marked as no longer in procesing for deposit."}, + }, + tags=["default"], + response_model_by_alias=True, +) +async def submission_id_unmark_processing_for_deposit_post( + submission_id: str = Path(..., description="Id of the submission to get."), +) -> None: + """Indicate that an external system in no longer working on depositing this submission. This does not indicate that is was successfully deposited. """ + if not BaseDefaultApi.subclasses: + raise HTTPException(status_code=500, detail="Not implemented") + return await BaseDefaultApi.subclasses[0]().submission_id_unmark_processing_for_deposit_post(submission_id) diff --git a/src/arxiv/submit_fastapi/main.py b/src/arxiv/submit_fastapi/main.py new file mode 100644 index 0000000..068221f --- /dev/null +++ b/src/arxiv/submit_fastapi/main.py @@ -0,0 +1,13 @@ +# coding: utf-8 + +""" + arxiv submit + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 0.1 + Contact: nextgen@arxiv.org + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 diff --git a/src/arxiv/submit_fastapi/models/__init__.py b/src/arxiv/submit_fastapi/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submit_fastapi/models/agreement.py b/src/arxiv/submit_fastapi/models/agreement.py new file mode 100644 index 0000000..5c3e30f --- /dev/null +++ b/src/arxiv/submit_fastapi/models/agreement.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + arxiv submit + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 0.1 + Contact: nextgen@arxiv.org + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + + + + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +class Agreement(BaseModel): + """ + The sender of this request agrees to the statement in the agreement + """ # noqa: E501 + submission_id: StrictStr + name: StrictStr + agreement: StrictStr + __properties: ClassVar[List[str]] = ["submission_id", "name", "agreement"] + + model_config = { + "populate_by_name": True, + "validate_assignment": True, + "protected_namespaces": (), + } + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of Agreement from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + _dict = self.model_dump( + by_alias=True, + exclude={ + }, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Dict) -> Self: + """Create an instance of Agreement from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "submission_id": obj.get("submission_id"), + "name": obj.get("name"), + "agreement": obj.get("agreement") + }) + return _obj + + diff --git a/src/arxiv/submit_fastapi/models/extra_models.py b/src/arxiv/submit_fastapi/models/extra_models.py new file mode 100644 index 0000000..a3a283f --- /dev/null +++ b/src/arxiv/submit_fastapi/models/extra_models.py @@ -0,0 +1,8 @@ +# coding: utf-8 + +from pydantic import BaseModel + +class TokenModel(BaseModel): + """Defines a token model.""" + + sub: str diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..33361c4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from arxiv.submit_fastapi import app as application + + +@pytest.fixture +def app() -> FastAPI: + application.dependency_overrides = {} + + return application + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) diff --git a/tests/test_default_api.py b/tests/test_default_api.py new file mode 100644 index 0000000..8eba8ca --- /dev/null +++ b/tests/test_default_api.py @@ -0,0 +1,161 @@ +# coding: utf-8 + +from fastapi.testclient import TestClient + + +from arxiv.submit_fastapi.models.agreement import Agreement # noqa: F401 + + +def test_get_service_status(client: TestClient): + """Test case for get_service_status + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "GET", + # "/status", + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_get_submission(client: TestClient): + """Test case for get_submission + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "GET", + # "/{submission_id}".format(submission_id='submission_id_example'), + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_new(client: TestClient): + """Test case for new + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "POST", + # "/", + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_submission_id_accept_policy_post(client: TestClient): + """Test case for submission_id_accept_policy_post + + + """ + agreement = {"submission_id":"submission_id","agreement":"agreement","name":"name"} + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "POST", + # "/{submission_id}/acceptPolicy".format(submission_id='submission_id_example'), + # headers=headers, + # json=agreement, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_submission_id_deposit_packet_packet_format_get(client: TestClient): + """Test case for submission_id_deposit_packet_packet_format_get + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "GET", + # "/{submission_id}/deposit_packet/{packet_format}".format(submission_id='submission_id_example', packet_format='packet_format_example'), + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_submission_id_deposited_post(client: TestClient): + """Test case for submission_id_deposited_post + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "POST", + # "/{submission_id}/Deposited".format(submission_id='submission_id_example'), + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_submission_id_mark_processing_for_deposit_post(client: TestClient): + """Test case for submission_id_mark_processing_for_deposit_post + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "POST", + # "/{submission_id}/markProcessingForDeposit".format(submission_id='submission_id_example'), + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + + +def test_submission_id_unmark_processing_for_deposit_post(client: TestClient): + """Test case for submission_id_unmark_processing_for_deposit_post + + + """ + + headers = { + } + # uncomment below to make a request + #response = client.request( + # "POST", + # "/{submission_id}/unmarkProcessingForDeposit".format(submission_id='submission_id_example'), + # headers=headers, + #) + + # uncomment below to assert the status code of the HTTP response + #assert response.status_code == 200 + From 7c40c4ebea4a136bf0c8563478783af5b4f16e1a Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Mon, 16 Sep 2024 16:31:21 -0400 Subject: [PATCH 06/28] Adds a stub legacy implementation --- README.md | 2 +- poetry.lock | 1598 +++++++++-------- pyproject.toml | 5 +- .../{models => api}/__init__.py | 0 .../submit_fastapi/{ => api}/default_api.py | 102 +- .../submit_fastapi/api/default_api_base.py | 64 + .../api/legacy_implementation.py | 40 + .../submit_fastapi/api/models/__init__.py | 0 .../{ => api}/models/agreement.py | 0 .../{ => api}/models/extra_models.py | 0 src/arxiv/submit_fastapi/app.py | 6 +- src/arxiv/submit_fastapi/config.py | 34 + src/arxiv/submit_fastapi/db.py | 32 + tests/test_default_api.py | 2 +- 14 files changed, 1038 insertions(+), 847 deletions(-) rename src/arxiv/submit_fastapi/{models => api}/__init__.py (100%) rename src/arxiv/submit_fastapi/{ => api}/default_api.py (55%) create mode 100644 src/arxiv/submit_fastapi/api/default_api_base.py create mode 100644 src/arxiv/submit_fastapi/api/legacy_implementation.py create mode 100644 src/arxiv/submit_fastapi/api/models/__init__.py rename src/arxiv/submit_fastapi/{ => api}/models/agreement.py (100%) rename src/arxiv/submit_fastapi/{ => api}/models/extra_models.py (100%) create mode 100644 src/arxiv/submit_fastapi/config.py create mode 100644 src/arxiv/submit_fastapi/db.py diff --git a/README.md b/README.md index aaf8016..bb68486 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ To run the server, please execute the following from the root directory: ```bash poetry install -submit_fastapi dev src/arxiv/submit_fastapi/main.py +fastapi dev src/arxiv/submit_fastapi/main.py ``` and open your browser at `http://localhost:8000/docs/` to see the docs. diff --git a/poetry.lock b/poetry.lock index a9199b3..b6b7547 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,48 +1,35 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] -name = "arxiv-base" -version = "1.0.1" -description = "Common code for arXiv Cloud" +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" optional = false -python-versions = "^3.11" -files = [] -develop = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] [package.dependencies] -bleach = "*" -fastly = "*" -fire = "^0.5.0" -flask = "3.0.*" -flask-s3 = "*" -google-auth = "^2.23.4" -google-cloud-logging = "^3.8.0" -google-cloud-monitoring = "^2.16.0" -google-cloud-pubsub = "^2.18.4" -google-cloud-storage = "^2.5.0" -markupsafe = "*" -mimesis = "*" -mysqlclient = ">=2.1" -pydantic = "==1.10.*" -pyjwt = "*" -pytz = "*" -redis = "==2.10.6" -redis-py-cluster = "==1.3.6" -retry = "^0.9.2" -setuptools = "^70.0.0" -sqlalchemy = "~=2.0.27" -typing-extensions = "*" -validators = "*" -wtforms = "*" +idna = ">=2.8" +sniffio = ">=1.1" [package.extras] -sphinx = ["sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-websupport"] - -[package.source] -type = "git" -url = "https://github.com/arXiv/arxiv-base.git" -reference = "develop" -resolved_reference = "25952ff7b63ca3a36ac8f947137f16b44c6c8fce" +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] [[package]] name = "astroid" @@ -135,84 +122,6 @@ files = [ {file = "backports_datetime_fromisoformat-2.0.2.tar.gz", hash = "sha256:142313bde1f93b0ea55f20f5a6ea034f84c79713daeb252dc47d40019db3812f"}, ] -[[package]] -name = "bleach" -version = "6.1.0" -description = "An easy safelist-based HTML-sanitizing tool." -optional = false -python-versions = ">=3.8" -files = [ - {file = "bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6"}, - {file = "bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe"}, -] - -[package.dependencies] -six = ">=1.9.0" -webencodings = "*" - -[package.extras] -css = ["tinycss2 (>=1.1.0,<1.3)"] - -[[package]] -name = "blinker" -version = "1.8.2" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.8" -files = [ - {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, - {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, -] - -[[package]] -name = "boto3" -version = "1.35.19" -description = "The AWS SDK for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "boto3-1.35.19-py3-none-any.whl", hash = "sha256:84b3fe1727945bc3cada832d969ddb3dc0d08fce1677064ca8bdc13a89c1a143"}, - {file = "boto3-1.35.19.tar.gz", hash = "sha256:9979fe674780a0b7100eae9156d74ee374cd1638a9f61c77277e3ce712f3e496"}, -] - -[package.dependencies] -botocore = ">=1.35.19,<1.36.0" -jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.10.0,<0.11.0" - -[package.extras] -crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] - -[[package]] -name = "botocore" -version = "1.35.19" -description = "Low-level, data-driven core of boto 3." -optional = false -python-versions = ">=3.8" -files = [ - {file = "botocore-1.35.19-py3-none-any.whl", hash = "sha256:c83f7f0cacfe7c19b109b363ebfa8736e570d24922f16ed371681f58ebab44a9"}, - {file = "botocore-1.35.19.tar.gz", hash = "sha256:42d6d8db7250cbd7899f786f9861e02cab17dc238f64d6acb976098ed9809625"}, -] - -[package.dependencies] -jmespath = ">=0.7.1,<2.0.0" -python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} - -[package.extras] -crt = ["awscrt (==0.21.5)"] - -[[package]] -name = "cachetools" -version = "5.5.0" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, -] - [[package]] name = "certifi" version = "2024.8.30" @@ -463,21 +372,24 @@ files = [ ] [[package]] -name = "deprecated" -version = "1.2.14" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +name = "dnspython" +version = "2.6.1" +description = "DNS toolkit" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, + {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, + {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, ] -[package.dependencies] -wrapt = ">=1.10,<2" - [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=41)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=0.9.25)"] +idna = ["idna (>=3.6)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] [[package]] name = "docker" @@ -512,332 +424,69 @@ files = [ ] [[package]] -name = "fastly" -version = "5.9.0" -description = "A Python Fastly API client library" -optional = false -python-versions = ">=3.6" -files = [ - {file = "fastly-5.9.0-py3-none-any.whl", hash = "sha256:ce5c20e59694bc77723322bf62b293fc89980aa1fc164f6f9b8703d10f703768"}, - {file = "fastly-5.9.0.tar.gz", hash = "sha256:a52e1baf0bbf3efab1e93e76321cf8288a9d386e7e5d9d018753e50e4f62fef6"}, -] - -[package.dependencies] -python-dateutil = "*" -urllib3 = ">=1.25.3" - -[[package]] -name = "fire" -version = "0.5.0" -description = "A library for automatically generating command line interfaces." -optional = false -python-versions = "*" -files = [ - {file = "fire-0.5.0.tar.gz", hash = "sha256:a6b0d49e98c8963910021f92bba66f65ab440da2982b78eb1bbf95a0a34aacc6"}, -] - -[package.dependencies] -six = "*" -termcolor = "*" - -[[package]] -name = "flask" -version = "3.0.3" -description = "A simple framework for building complex web applications." +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" files = [ - {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, - {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, -] - -[package.dependencies] -blinker = ">=1.6.2" -click = ">=8.1.3" -itsdangerous = ">=2.1.2" -Jinja2 = ">=3.1.2" -Werkzeug = ">=3.0.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - -[[package]] -name = "flask-s3" -version = "0.3.3" -description = "Seamlessly serve the static files of your Flask app from Amazon S3" -optional = false -python-versions = "*" -files = [ - {file = "Flask-S3-0.3.3.tar.gz", hash = "sha256:1d49061d4b78759df763358a901f4ed32bb43f672c9f8e1ec7226793f6ae0fd2"}, - {file = "Flask_S3-0.3.3-py3-none-any.whl", hash = "sha256:23cbbb1db4c29c313455dbe16f25be078d6318f0a11abcbb610f99e116945b62"}, + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, ] [package.dependencies] -Boto3 = ">=1.1.1" -Flask = "*" -six = "*" +dnspython = ">=2.0.0" +idna = ">=2.0.0" [[package]] -name = "google-api-core" -version = "2.19.2" -description = "Google API client core library" +name = "fastapi" +version = "0.114.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.7" -files = [ - {file = "google_api_core-2.19.2-py3-none-any.whl", hash = "sha256:53ec0258f2837dd53bbd3d3df50f5359281b3cc13f800c941dd15a9b5a415af4"}, - {file = "google_api_core-2.19.2.tar.gz", hash = "sha256:ca07de7e8aa1c98a8bfca9321890ad2340ef7f2eb136e558cee68f24b94b0a8f"}, -] - -[package.dependencies] -google-auth = ">=2.14.1,<3.0.dev0" -googleapis-common-protos = ">=1.56.2,<2.0.dev0" -grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" -requests = ">=2.18.0,<3.0.0.dev0" - -[package.extras] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] - -[[package]] -name = "google-auth" -version = "2.34.0" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_auth-2.34.0-py2.py3-none-any.whl", hash = "sha256:72fd4733b80b6d777dcde515628a9eb4a577339437012874ea286bca7261ee65"}, - {file = "google_auth-2.34.0.tar.gz", hash = "sha256:8eb87396435c19b20d32abd2f984e31c191a15284af72eb922f10e5bde9c04cc"}, -] - -[package.dependencies] -cachetools = ">=2.0.0,<6.0" -pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" - -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] -enterprise-cert = ["cryptography", "pyopenssl"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] -reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] - -[[package]] -name = "google-cloud-appengine-logging" -version = "1.4.5" -description = "Google Cloud Appengine Logging API client library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_appengine_logging-1.4.5-py2.py3-none-any.whl", hash = "sha256:344e0244404049b42164e4d6dc718ca2c81b393d066956e7cb85fd9407ed9c48"}, - {file = "google_cloud_appengine_logging-1.4.5.tar.gz", hash = "sha256:de7d766e5d67b19fc5833974b505b32d2a5bbdfb283fd941e320e7cfdae4cb83"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" - -[[package]] -name = "google-cloud-audit-log" -version = "0.3.0" -description = "Google Cloud Audit Protos" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_audit_log-0.3.0-py2.py3-none-any.whl", hash = "sha256:8340793120a1d5aa143605def8704ecdcead15106f754ef1381ae3bab533722f"}, - {file = "google_cloud_audit_log-0.3.0.tar.gz", hash = "sha256:901428b257020d8c1d1133e0fa004164a555e5a395c7ca3cdbb8486513df3a65"}, -] - -[package.dependencies] -googleapis-common-protos = ">=1.56.2,<2.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" - -[[package]] -name = "google-cloud-core" -version = "2.4.1" -description = "Google Cloud API client core library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, - {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, -] - -[package.dependencies] -google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" -google-auth = ">=1.25.0,<3.0dev" - -[package.extras] -grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] - -[[package]] -name = "google-cloud-logging" -version = "3.11.2" -description = "Stackdriver Logging API client library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_logging-3.11.2-py2.py3-none-any.whl", hash = "sha256:0a755f04f184fbe77ad608258dc283a032485ebb4d0e2b2501964059ee9c898f"}, - {file = "google_cloud_logging-3.11.2.tar.gz", hash = "sha256:4897441c2b74f6eda9181c23a8817223b6145943314a821d64b729d30766cb2b"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -google-cloud-appengine-logging = ">=0.1.3,<2.0.0dev" -google-cloud-audit-log = ">=0.2.4,<1.0.0dev" -google-cloud-core = ">=2.0.0,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" -opentelemetry-api = ">=1.9.0" -proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" - -[[package]] -name = "google-cloud-monitoring" -version = "2.22.2" -description = "Google Cloud Monitoring API client library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_monitoring-2.22.2-py2.py3-none-any.whl", hash = "sha256:3f07aead6a80a894c5f8e151f1cccf78478eab14e14294f4b83aaa3f478b5c4e"}, - {file = "google_cloud_monitoring-2.22.2.tar.gz", hash = "sha256:9fc22dac48d14dd1c7fb83ee4a54f7a57bf9852ee6b5c9dca9e3bb093b04c7ee"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" - -[package.extras] -pandas = ["pandas (>=0.23.2)"] - -[[package]] -name = "google-cloud-pubsub" -version = "2.23.1" -description = "Google Cloud Pub/Sub API client library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_pubsub-2.23.1-py2.py3-none-any.whl", hash = "sha256:a173292a699851eb622016d3f2796ecf2d69692e708ea0e7382f338fc1679f8a"}, - {file = "google_cloud_pubsub-2.23.1.tar.gz", hash = "sha256:e1fde79b5b64b721290af4c022907afcbb83512d92f4e5c334c391cfbb022acb"}, -] - -[package.dependencies] -google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" -grpcio = ">=1.51.3,<2.0dev" -grpcio-status = ">=1.33.2" -proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" - -[package.extras] -libcst = ["libcst (>=0.3.10)"] - -[[package]] -name = "google-cloud-storage" -version = "2.18.2" -description = "Google Cloud Storage API client library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "google_cloud_storage-2.18.2-py2.py3-none-any.whl", hash = "sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166"}, - {file = "google_cloud_storage-2.18.2.tar.gz", hash = "sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99"}, -] - -[package.dependencies] -google-api-core = ">=2.15.0,<3.0.0dev" -google-auth = ">=2.26.1,<3.0dev" -google-cloud-core = ">=2.3.0,<3.0dev" -google-crc32c = ">=1.0,<2.0dev" -google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0dev" - -[package.extras] -protobuf = ["protobuf (<6.0.0dev)"] -tracing = ["opentelemetry-api (>=1.1.0)"] - -[[package]] -name = "google-crc32c" -version = "1.6.0" -description = "A python wrapper of the C library 'Google CRC32C'" -optional = false -python-versions = ">=3.9" -files = [ - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, - {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, - {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, - {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, - {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, -] - -[package.extras] -testing = ["pytest"] - -[[package]] -name = "google-resumable-media" -version = "2.7.2" -description = "Utilities for Google Media Downloads and Resumable Uploads" -optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, - {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, + {file = "fastapi-0.114.2-py3-none-any.whl", hash = "sha256:44474a22913057b1acb973ab90f4b671ba5200482e7622816d79105dcece1ac5"}, + {file = "fastapi-0.114.2.tar.gz", hash = "sha256:0adb148b62edb09e8c6eeefa3ea934e8f276dabc038c5a82989ea6346050c3da"}, ] [package.dependencies] -google-crc32c = ">=1.0,<2.0dev" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} +fastapi-cli = {version = ">=0.0.5", extras = ["standard"], optional = true, markers = "extra == \"all\""} +httpx = {version = ">=0.23.0", optional = true, markers = "extra == \"all\""} +itsdangerous = {version = ">=1.1.0", optional = true, markers = "extra == \"all\""} +jinja2 = {version = ">=2.11.2", optional = true, markers = "extra == \"all\""} +orjson = {version = ">=3.2.1", optional = true, markers = "extra == \"all\""} +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +pydantic-extra-types = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} +pydantic-settings = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} +python-multipart = {version = ">=0.0.7", optional = true, markers = "extra == \"all\""} +pyyaml = {version = ">=5.3.1", optional = true, markers = "extra == \"all\""} +starlette = ">=0.37.2,<0.39.0" +typing-extensions = ">=4.8.0" +ujson = {version = ">=4.0.1,<4.0.2 || >4.0.2,<4.1.0 || >4.1.0,<4.2.0 || >4.2.0,<4.3.0 || >4.3.0,<5.0.0 || >5.0.0,<5.1.0 || >5.1.0", optional = true, markers = "extra == \"all\""} +uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers = "extra == \"all\""} [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] -requests = ["requests (>=2.18.0,<3.0.0dev)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] [[package]] -name = "googleapis-common-protos" -version = "1.65.0" -description = "Common protobufs used in Google APIs" +name = "fastapi-cli" +version = "0.0.5" +description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "googleapis_common_protos-1.65.0-py2.py3-none-any.whl", hash = "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63"}, - {file = "googleapis_common_protos-1.65.0.tar.gz", hash = "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0"}, + {file = "fastapi_cli-0.0.5-py3-none-any.whl", hash = "sha256:e94d847524648c748a5350673546bbf9bcaeb086b33c24f2e82e021436866a46"}, + {file = "fastapi_cli-0.0.5.tar.gz", hash = "sha256:d30e1239c6f46fcb95e606f02cdda59a1e2fa778a54b64686b3ff27f6211ff9f"}, ] [package.dependencies] -grpcio = {version = ">=1.44.0,<2.0.0.dev0", optional = true, markers = "extra == \"grpc\""} -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +typer = ">=0.12.3" +uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] +standard = ["uvicorn[standard] (>=0.15.0)"] [[package]] name = "greenlet" @@ -919,94 +568,109 @@ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] [[package]] -name = "grpc-google-iam-v1" -version = "0.13.1" -description = "IAM API client library" +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" files = [ - {file = "grpc-google-iam-v1-0.13.1.tar.gz", hash = "sha256:3ff4b2fd9d990965e410965253c0da6f66205d5a8291c4c31c6ebecca18a9001"}, - {file = "grpc_google_iam_v1-0.13.1-py2.py3-none-any.whl", hash = "sha256:c3e86151a981811f30d5e7330f271cee53e73bb87755e88cc3b6f0c7b5fe374e"}, + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, ] [package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] -name = "grpcio" -version = "1.66.1" -description = "HTTP/2-based RPC framework" +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." optional = false -python-versions = ">=3.8" +python-versions = ">=3.8.0" files = [ - {file = "grpcio-1.66.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:4877ba180591acdf127afe21ec1c7ff8a5ecf0fe2600f0d3c50e8c4a1cbc6492"}, - {file = "grpcio-1.66.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:3750c5a00bd644c75f4507f77a804d0189d97a107eb1481945a0cf3af3e7a5ac"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:a013c5fbb12bfb5f927444b477a26f1080755a931d5d362e6a9a720ca7dbae60"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1b24c23d51a1e8790b25514157d43f0a4dce1ac12b3f0b8e9f66a5e2c4c132f"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7ffb8ea674d68de4cac6f57d2498fef477cef582f1fa849e9f844863af50083"}, - {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:307b1d538140f19ccbd3aed7a93d8f71103c5d525f3c96f8616111614b14bf2a"}, - {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c17ebcec157cfb8dd445890a03e20caf6209a5bd4ac5b040ae9dbc59eef091d"}, - {file = "grpcio-1.66.1-cp310-cp310-win32.whl", hash = "sha256:ef82d361ed5849d34cf09105d00b94b6728d289d6b9235513cb2fcc79f7c432c"}, - {file = "grpcio-1.66.1-cp310-cp310-win_amd64.whl", hash = "sha256:292a846b92cdcd40ecca46e694997dd6b9be6c4c01a94a0dfb3fcb75d20da858"}, - {file = "grpcio-1.66.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:c30aeceeaff11cd5ddbc348f37c58bcb96da8d5aa93fed78ab329de5f37a0d7a"}, - {file = "grpcio-1.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8a1e224ce6f740dbb6b24c58f885422deebd7eb724aff0671a847f8951857c26"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a66fe4dc35d2330c185cfbb42959f57ad36f257e0cc4557d11d9f0a3f14311df"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ba04659e4fce609de2658fe4dbf7d6ed21987a94460f5f92df7579fd5d0e22"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4573608e23f7e091acfbe3e84ac2045680b69751d8d67685ffa193a4429fedb1"}, - {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7e06aa1f764ec8265b19d8f00140b8c4b6ca179a6dc67aa9413867c47e1fb04e"}, - {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3885f037eb11f1cacc41f207b705f38a44b69478086f40608959bf5ad85826dd"}, - {file = "grpcio-1.66.1-cp311-cp311-win32.whl", hash = "sha256:97ae7edd3f3f91480e48ede5d3e7d431ad6005bfdbd65c1b56913799ec79e791"}, - {file = "grpcio-1.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:cfd349de4158d797db2bd82d2020554a121674e98fbe6b15328456b3bf2495bb"}, - {file = "grpcio-1.66.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:a92c4f58c01c77205df6ff999faa008540475c39b835277fb8883b11cada127a"}, - {file = "grpcio-1.66.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fdb14bad0835914f325349ed34a51940bc2ad965142eb3090081593c6e347be9"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f03a5884c56256e08fd9e262e11b5cfacf1af96e2ce78dc095d2c41ccae2c80d"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2559692d8e7e245d456877a85ee41525f3ed425aa97eb7a70fc9a79df91a0"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca1be089fb4446490dd1135828bd42a7c7f8421e74fa581611f7afdf7ab761"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d639c939ad7c440c7b2819a28d559179a4508783f7e5b991166f8d7a34b52815"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b9feb4e5ec8dc2d15709f4d5fc367794d69277f5d680baf1910fc9915c633524"}, - {file = "grpcio-1.66.1-cp312-cp312-win32.whl", hash = "sha256:7101db1bd4cd9b880294dec41a93fcdce465bdbb602cd8dc5bd2d6362b618759"}, - {file = "grpcio-1.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:b0aa03d240b5539648d996cc60438f128c7f46050989e35b25f5c18286c86734"}, - {file = "grpcio-1.66.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:ecfe735e7a59e5a98208447293ff8580e9db1e890e232b8b292dc8bd15afc0d2"}, - {file = "grpcio-1.66.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4825a3aa5648010842e1c9d35a082187746aa0cdbf1b7a2a930595a94fb10fce"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f517fd7259fe823ef3bd21e508b653d5492e706e9f0ef82c16ce3347a8a5620c"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1fe60d0772831d96d263b53d83fb9a3d050a94b0e94b6d004a5ad111faa5b5b"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31a049daa428f928f21090403e5d18ea02670e3d5d172581670be006100db9ef"}, - {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f914386e52cbdeb5d2a7ce3bf1fdfacbe9d818dd81b6099a05b741aaf3848bb"}, - {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bff2096bdba686019fb32d2dde45b95981f0d1490e054400f70fc9a8af34b49d"}, - {file = "grpcio-1.66.1-cp38-cp38-win32.whl", hash = "sha256:aa8ba945c96e73de29d25331b26f3e416e0c0f621e984a3ebdb2d0d0b596a3b3"}, - {file = "grpcio-1.66.1-cp38-cp38-win_amd64.whl", hash = "sha256:161d5c535c2bdf61b95080e7f0f017a1dfcb812bf54093e71e5562b16225b4ce"}, - {file = "grpcio-1.66.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:d0cd7050397b3609ea51727b1811e663ffda8bda39c6a5bb69525ef12414b503"}, - {file = "grpcio-1.66.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0e6c9b42ded5d02b6b1fea3a25f036a2236eeb75d0579bfd43c0018c88bf0a3e"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:c9f80f9fad93a8cf71c7f161778ba47fd730d13a343a46258065c4deb4b550c0"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dd67ed9da78e5121efc5c510f0122a972216808d6de70953a740560c572eb44"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b0d92d45ce3be2084b92fb5bae2f64c208fea8ceed7fccf6a7b524d3c4942e"}, - {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d813316d1a752be6f5c4360c49f55b06d4fe212d7df03253dfdae90c8a402bb"}, - {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9c9bebc6627873ec27a70fc800f6083a13c70b23a5564788754b9ee52c5aef6c"}, - {file = "grpcio-1.66.1-cp39-cp39-win32.whl", hash = "sha256:30a1c2cf9390c894c90bbc70147f2372130ad189cffef161f0432d0157973f45"}, - {file = "grpcio-1.66.1-cp39-cp39-win_amd64.whl", hash = "sha256:17663598aadbedc3cacd7bbde432f541c8e07d2496564e22b214b22c7523dac8"}, - {file = "grpcio-1.66.1.tar.gz", hash = "sha256:35334f9c9745add3e357e3372756fd32d925bd52c41da97f4dfdafbde0bf0ee2"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.66.1)"] +test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] -name = "grpcio-status" -version = "1.66.1" -description = "Status proto mapping for gRPC" +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "grpcio_status-1.66.1-py3-none-any.whl", hash = "sha256:cf9ed0b4a83adbe9297211c95cb5488b0cd065707e812145b842c85c4782ff02"}, - {file = "grpcio_status-1.66.1.tar.gz", hash = "sha256:b3f7d34ccc46d83fea5261eea3786174459f763c31f6e34f1d24eba6d515d024"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] -googleapis-common-protos = ">=1.5.5" -grpcio = ">=1.66.1" -protobuf = ">=5.26.1,<6.0dev" +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" @@ -1022,25 +686,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.4.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, - {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -1094,17 +739,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jmespath" -version = "1.0.1" -description = "JSON Matching Expressions" -optional = false -python-versions = ">=3.7" -files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, -] - [[package]] name = "jsonschema" version = "4.23.0" @@ -1203,6 +837,30 @@ files = [ {file = "lazy_object_proxy-1.10.0-pp310.pp311.pp312.pp38.pp39-none-any.whl", hash = "sha256:80fa48bd89c8f2f456fc0765c11c23bf5af827febacd2f523ca5bc1893fcc09d"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "2.1.5" @@ -1283,6 +941,17 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mimesis" version = "18.0.0" @@ -1355,24 +1024,6 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] -[[package]] -name = "mysqlclient" -version = "2.2.4" -description = "Python interface to MySQL" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mysqlclient-2.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5"}, - {file = "mysqlclient-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711"}, - {file = "mysqlclient-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab"}, - {file = "mysqlclient-2.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:3c318755e06df599338dad7625f884b8a71fcf322a9939ef78c9b3db93e1de7a"}, - {file = "mysqlclient-2.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:9d4c015480c4a6b2b1602eccd9846103fc70606244788d04aa14b31c4bd1f0e2"}, - {file = "mysqlclient-2.2.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54"}, - {file = "mysqlclient-2.2.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4e80dcad884dd6e14949ac6daf769123223a52a6805345608bf49cdaf7bc8b3a"}, - {file = "mysqlclient-2.2.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9d3310295cb682232cadc28abd172f406c718b9ada41d2371259098ae37779d3"}, - {file = "mysqlclient-2.2.4.tar.gz", hash = "sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41"}, -] - [[package]] name = "openapi-schema-validator" version = "0.6.2" @@ -1407,20 +1058,71 @@ lazy-object-proxy = ">=1.7.1,<2.0.0" openapi-schema-validator = ">=0.6.0,<0.7.0" [[package]] -name = "opentelemetry-api" -version = "1.27.0" -description = "OpenTelemetry Python API" +name = "orjson" +version = "3.10.7" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7"}, - {file = "opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342"}, + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, ] -[package.dependencies] -deprecated = ">=1.2.6" -importlib-metadata = ">=6.0,<=8.4.0" - [[package]] name = "packaging" version = "24.1" @@ -1459,136 +1161,181 @@ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] -name = "proto-plus" -version = "1.24.0" -description = "Beautiful, Pythonic protocol buffers." +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, - {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] -[package.dependencies] -protobuf = ">=3.19.0,<6.0.0dev" - -[package.extras] -testing = ["google-api-core (>=1.31.5)"] - [[package]] -name = "protobuf" -version = "5.28.1" -description = "" +name = "pydantic" +version = "2.9.1" +description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-5.28.1-cp310-abi3-win32.whl", hash = "sha256:fc063acaf7a3d9ca13146fefb5b42ac94ab943ec6e978f543cd5637da2d57957"}, - {file = "protobuf-5.28.1-cp310-abi3-win_amd64.whl", hash = "sha256:4c7f5cb38c640919791c9f74ea80c5b82314c69a8409ea36f2599617d03989af"}, - {file = "protobuf-5.28.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4304e4fceb823d91699e924a1fdf95cde0e066f3b1c28edb665bda762ecde10f"}, - {file = "protobuf-5.28.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:0dfd86d2b5edf03d91ec2a7c15b4e950258150f14f9af5f51c17fa224ee1931f"}, - {file = "protobuf-5.28.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:51f09caab818707ab91cf09cc5c156026599cf05a4520779ccbf53c1b352fb25"}, - {file = "protobuf-5.28.1-cp38-cp38-win32.whl", hash = "sha256:1b04bde117a10ff9d906841a89ec326686c48ececeb65690f15b8cabe7149495"}, - {file = "protobuf-5.28.1-cp38-cp38-win_amd64.whl", hash = "sha256:cabfe43044ee319ad6832b2fda332646f9ef1636b0130186a3ae0a52fc264bb4"}, - {file = "protobuf-5.28.1-cp39-cp39-win32.whl", hash = "sha256:4b4b9a0562a35773ff47a3df823177ab71a1f5eb1ff56d8f842b7432ecfd7fd2"}, - {file = "protobuf-5.28.1-cp39-cp39-win_amd64.whl", hash = "sha256:f24e5d70e6af8ee9672ff605d5503491635f63d5db2fffb6472be78ba62efd8f"}, - {file = "protobuf-5.28.1-py3-none-any.whl", hash = "sha256:c529535e5c0effcf417682563719e5d8ac8d2b93de07a56108b4c2d436d7a29a"}, - {file = "protobuf-5.28.1.tar.gz", hash = "sha256:42597e938f83bb7f3e4b35f03aa45208d49ae8d5bcb4bc10b9fc825e0ab5e423"}, + {file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"}, + {file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"}, ] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.3" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, ] +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + [[package]] -name = "pyasn1" -version = "0.6.1" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +name = "pydantic-core" +version = "2.23.3" +description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, + {file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"}, + {file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"}, + {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"}, + {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"}, + {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"}, + {file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"}, + {file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"}, + {file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"}, + {file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"}, + {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"}, + {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"}, + {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"}, + {file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"}, + {file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"}, + {file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"}, + {file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"}, + {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"}, + {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"}, + {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"}, + {file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"}, + {file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"}, + {file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"}, + {file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"}, + {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"}, + {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"}, + {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"}, + {file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"}, + {file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"}, + {file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"}, + {file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"}, + {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"}, + {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"}, + {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"}, + {file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"}, + {file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"}, + {file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"}, + {file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"}, + {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"}, + {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"}, + {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"}, + {file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"}, + {file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"}, + {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"}, + {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"}, + {file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"}, ] +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] -name = "pyasn1-modules" -version = "0.4.1" -description = "A collection of ASN.1-based protocols modules" +name = "pydantic-extra-types" +version = "2.9.0" +description = "Extra Pydantic types." optional = false python-versions = ">=3.8" files = [ - {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, - {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, + {file = "pydantic_extra_types-2.9.0-py3-none-any.whl", hash = "sha256:f0bb975508572ba7bf3390b7337807588463b7248587e69f43b1ad7c797530d0"}, + {file = "pydantic_extra_types-2.9.0.tar.gz", hash = "sha256:e061c01636188743bb69f368dcd391f327b8cfbfede2fe1cbb1211b06601ba3b"}, ] [package.dependencies] -pyasn1 = ">=0.4.6,<0.7.0" +pydantic = ">=2.5.2" + +[package.extras] +all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<3)", "pytz (>=2024.1)", "semver (>=3.0.2)", "tzdata (>=2024.1)"] +pendulum = ["pendulum (>=3.0.0,<4.0.0)"] +phonenumbers = ["phonenumbers (>=8,<9)"] +pycountry = ["pycountry (>=23)"] +python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<3)"] +semver = ["semver (>=3.0.2)"] [[package]] -name = "pydantic" -version = "1.10.18" -description = "Data validation and settings management using python type hints" +name = "pydantic-settings" +version = "2.5.2" +description = "Settings management using Pydantic" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, - {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, - {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, - {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, - {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, - {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, - {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, - {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, - {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, - {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, - {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, - {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, - {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, - {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, - {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, - {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, - {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, - {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, - {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, - {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, - {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, - {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, - {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, - {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, - {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, - {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, - {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, - {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, - {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, - {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, - {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, - {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, - {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, - {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, - {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, - {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, - {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, - {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, - {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, - {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, - {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, - {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, - {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, + {file = "pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907"}, + {file = "pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pydocstyle" @@ -1607,21 +1354,18 @@ six = "*" snowballstemmer = "*" [[package]] -name = "pyjwt" -version = "2.9.0" -description = "JSON Web Token implementation in Python" +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ - {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, - {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] [package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" @@ -1693,6 +1437,34 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.9" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + +[package.extras] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] + [[package]] name = "pytz" version = "2018.7" @@ -1789,30 +1561,6 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] -[[package]] -name = "redis" -version = "2.10.6" -description = "Python client for Redis key-value store" -optional = false -python-versions = "*" -files = [ - {file = "redis-2.10.6-py2.py3-none-any.whl", hash = "sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb"}, - {file = "redis-2.10.6.tar.gz", hash = "sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"}, -] - -[[package]] -name = "redis-py-cluster" -version = "1.3.6" -description = "Library for communicating with Redis Clusters. Built on top of redis-py lib" -optional = false -python-versions = "*" -files = [ - {file = "redis-py-cluster-1.3.6.tar.gz", hash = "sha256:7db54b1de60bd34da3806676b112f07fc9afae556d8260ac02c3335d574ee42c"}, -] - -[package.dependencies] -redis = "2.10.6" - [[package]] name = "referencing" version = "0.35.1" @@ -1892,6 +1640,24 @@ files = [ [package.dependencies] six = "*" +[[package]] +name = "rich" +version = "13.8.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, + {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rpds-py" version = "0.20.0" @@ -2004,37 +1770,6 @@ files = [ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, ] -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -optional = false -python-versions = ">=3.6,<4" -files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "s3transfer" -version = "0.10.2" -description = "An Amazon S3 Transfer Manager" -optional = false -python-versions = ">=3.8" -files = [ - {file = "s3transfer-0.10.2-py3-none-any.whl", hash = "sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69"}, - {file = "s3transfer-0.10.2.tar.gz", hash = "sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6"}, -] - -[package.dependencies] -botocore = ">=1.33.2,<2.0a.0" - -[package.extras] -crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] - [[package]] name = "semver" version = "3.0.2" @@ -2047,20 +1782,16 @@ files = [ ] [[package]] -name = "setuptools" -version = "70.3.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, - {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] -[package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -2072,6 +1803,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -2171,18 +1913,38 @@ pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] [[package]] -name = "termcolor" -version = "2.4.0" -description = "ANSI color formatting for output in terminal" +name = "starlette" +version = "0.38.5" +description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, - {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, + {file = "starlette-0.38.5-py3-none-any.whl", hash = "sha256:632f420a9d13e3ee2a6f18f437b0a9f1faecb0bc42e1942aa2ea0e379a4c4206"}, + {file = "starlette-0.38.5.tar.gz", hash = "sha256:04a92830a9b6eb1442c766199d62260c3d4dc9c4f9188360626b1e0273cb7077"}, ] +[package.dependencies] +anyio = ">=3.4.0,<5" + [package.extras] -tests = ["pytest", "pytest-cov"] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "typer" +version = "0.12.5" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, + {file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" @@ -2195,6 +1957,93 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "ujson" +version = "5.10.0" +description = "Ultra fast JSON encoder and decoder for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, + {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, + {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, + {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, + {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, + {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, + {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, + {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, + {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, + {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, + {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, + {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, + {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, + {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, + {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, + {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, + {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, + {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, + {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, + {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, + {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, + {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, + {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, + {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, + {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, + {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, + {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, + {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, + {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, + {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, + {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, + {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, + {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, + {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, + {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, +] + [[package]] name = "unidecode" version = "1.3.8" @@ -2224,46 +2073,263 @@ socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] -name = "validators" -version = "0.34.0" -description = "Python Data Validation for Humans™" +name = "uvicorn" +version = "0.30.6" +description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "validators-0.34.0-py3-none-any.whl", hash = "sha256:c804b476e3e6d3786fa07a30073a4ef694e617805eb1946ceee3fe5a9b8b1321"}, - {file = "validators-0.34.0.tar.gz", hash = "sha256:647fe407b45af9a74d245b943b18e6a816acf4926974278f6dd617778e1e781f"}, + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, ] +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + [package.extras] -crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] -name = "webencodings" -version = "0.5.1" -description = "Character encoding aliases for legacy web content" +name = "uvloop" +version = "0.20.0" +description = "Fast implementation of asyncio event loop on top of libuv" optional = false -python-versions = "*" +python-versions = ">=3.8.0" files = [ - {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, - {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9ebafa0b96c62881d5cafa02d9da2e44c23f9f0cd829f3a32a6aff771449c996"}, + {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:35968fc697b0527a06e134999eef859b4034b37aebca537daeb598b9d45a137b"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b16696f10e59d7580979b420eedf6650010a4a9c3bd8113f24a103dfdb770b10"}, + {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b04d96188d365151d1af41fa2d23257b674e7ead68cfd61c725a422764062ae"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:94707205efbe809dfa3a0d09c08bef1352f5d3d6612a506f10a319933757c006"}, + {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89e8d33bb88d7263f74dc57d69f0063e06b5a5ce50bb9a6b32f5fcbe655f9e73"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037"}, + {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e"}, + {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0"}, + {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d"}, + {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9"}, + {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5"}, + {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0e94b221295b5e69de57a1bd4aeb0b3a29f61be6e1b478bb8a69a73377db7ba"}, + {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fee6044b64c965c425b65a4e17719953b96e065c5b7e09b599ff332bb2744bdf"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:265a99a2ff41a0fd56c19c3838b29bf54d1d177964c300dad388b27e84fd7847"}, + {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10c2956efcecb981bf9cfb8184d27d5d64b9033f917115a960b83f11bfa0d6b"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e7d61fe8e8d9335fac1bf8d5d82820b4808dd7a43020c149b63a1ada953d48a6"}, + {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2beee18efd33fa6fdb0976e18475a4042cd31c7433c866e8a09ab604c7c22ff2"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8c36fdf3e02cec92aed2d44f63565ad1522a499c654f07935c8f9d04db69e95"}, + {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0fac7be202596c7126146660725157d4813aa29a4cc990fe51346f75ff8fde7"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0fba61846f294bce41eb44d60d58136090ea2b5b99efd21cbdf4e21927c56a"}, + {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95720bae002ac357202e0d866128eb1ac82545bcf0b549b9abe91b5178d9b541"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:36c530d8fa03bfa7085af54a48f2ca16ab74df3ec7108a46ba82fd8b411a2315"}, + {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e97152983442b499d7a71e44f29baa75b3b02e65d9c44ba53b10338e98dedb66"}, + {file = "uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469"}, ] +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + [[package]] -name = "werkzeug" -version = "3.0.4" -description = "The comprehensive WSGI web application library." +name = "watchfiles" +version = "0.24.0" +description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.8" files = [ - {file = "werkzeug-3.0.4-py3-none-any.whl", hash = "sha256:02c9eb92b7d6c06f31a782811505d2157837cea66aaede3e217c7c27c039476c"}, - {file = "werkzeug-3.0.4.tar.gz", hash = "sha256:34f2371506b250df4d4f84bfe7b0921e4762525762bbd936614909fe25cd7306"}, + {file = "watchfiles-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:083dc77dbdeef09fa44bb0f4d1df571d2e12d8a8f985dccde71ac3ac9ac067a0"}, + {file = "watchfiles-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e94e98c7cb94cfa6e071d401ea3342767f28eb5a06a58fafdc0d2a4974f4f35c"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82ae557a8c037c42a6ef26c494d0631cacca040934b101d001100ed93d43f361"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acbfa31e315a8f14fe33e3542cbcafc55703b8f5dcbb7c1eecd30f141df50db3"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74fdffce9dfcf2dc296dec8743e5b0332d15df19ae464f0e249aa871fc1c571"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:449f43f49c8ddca87c6b3980c9284cab6bd1f5c9d9a2b00012adaaccd5e7decd"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4abf4ad269856618f82dee296ac66b0cd1d71450fc3c98532d93798e73399b7a"}, + {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f895d785eb6164678ff4bb5cc60c5996b3ee6df3edb28dcdeba86a13ea0465e"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ae3e208b31be8ce7f4c2c0034f33406dd24fbce3467f77223d10cd86778471c"}, + {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2efec17819b0046dde35d13fb8ac7a3ad877af41ae4640f4109d9154ed30a188"}, + {file = "watchfiles-0.24.0-cp310-none-win32.whl", hash = "sha256:6bdcfa3cd6fdbdd1a068a52820f46a815401cbc2cb187dd006cb076675e7b735"}, + {file = "watchfiles-0.24.0-cp310-none-win_amd64.whl", hash = "sha256:54ca90a9ae6597ae6dc00e7ed0a040ef723f84ec517d3e7ce13e63e4bc82fa04"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428"}, + {file = "watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15"}, + {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab"}, + {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec"}, + {file = "watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d"}, + {file = "watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c"}, + {file = "watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a"}, + {file = "watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f"}, + {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef"}, + {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968"}, + {file = "watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444"}, + {file = "watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896"}, + {file = "watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48"}, + {file = "watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab"}, + {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b"}, + {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18"}, + {file = "watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07"}, + {file = "watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ee82c98bed9d97cd2f53bdb035e619309a098ea53ce525833e26b93f673bc318"}, + {file = "watchfiles-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fd92bbaa2ecdb7864b7600dcdb6f2f1db6e0346ed425fbd01085be04c63f0b05"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f83df90191d67af5a831da3a33dd7628b02a95450e168785586ed51e6d28943c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fca9433a45f18b7c779d2bae7beeec4f740d28b788b117a48368d95a3233ed83"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b995bfa6bf01a9e09b884077a6d37070464b529d8682d7691c2d3b540d357a0c"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed9aba6e01ff6f2e8285e5aa4154e2970068fe0fc0998c4380d0e6278222269b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5171ef898299c657685306d8e1478a45e9303ddcd8ac5fed5bd52ad4ae0b69b"}, + {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4933a508d2f78099162da473841c652ad0de892719043d3f07cc83b33dfd9d91"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95cf3b95ea665ab03f5a54765fa41abf0529dbaf372c3b83d91ad2cfa695779b"}, + {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:01def80eb62bd5db99a798d5e1f5f940ca0a05986dcfae21d833af7a46f7ee22"}, + {file = "watchfiles-0.24.0-cp38-none-win32.whl", hash = "sha256:4d28cea3c976499475f5b7a2fec6b3a36208656963c1a856d328aeae056fc5c1"}, + {file = "watchfiles-0.24.0-cp38-none-win_amd64.whl", hash = "sha256:21ab23fdc1208086d99ad3f69c231ba265628014d4aed31d4e8746bd59e88cd1"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b665caeeda58625c3946ad7308fbd88a086ee51ccb706307e5b1fa91556ac886"}, + {file = "watchfiles-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c51749f3e4e269231510da426ce4a44beb98db2dce9097225c338f815b05d4f"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b2509f08761f29a0fdad35f7e1638b8ab1adfa2666d41b794090361fb8b855"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a60e2bf9dc6afe7f743e7c9b149d1fdd6dbf35153c78fe3a14ae1a9aee3d98b"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7d9b87c4c55e3ea8881dfcbf6d61ea6775fffed1fedffaa60bd047d3c08c430"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78470906a6be5199524641f538bd2c56bb809cd4bf29a566a75051610bc982c3"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07cdef0c84c03375f4e24642ef8d8178e533596b229d32d2bbd69e5128ede02a"}, + {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d337193bbf3e45171c8025e291530fb7548a93c45253897cd764a6a71c937ed9"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ec39698c45b11d9694a1b635a70946a5bad066b593af863460a8e600f0dff1ca"}, + {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e28d91ef48eab0afb939fa446d8ebe77e2f7593f5f463fd2bb2b14132f95b6e"}, + {file = "watchfiles-0.24.0-cp39-none-win32.whl", hash = "sha256:7138eff8baa883aeaa074359daabb8b6c1e73ffe69d5accdc907d62e50b1c0da"}, + {file = "watchfiles-0.24.0-cp39-none-win_amd64.whl", hash = "sha256:b3ef2c69c655db63deb96b3c3e587084612f9b1fa983df5e0c3379d41307467f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:632676574429bee8c26be8af52af20e0c718cc7f5f67f3fb658c71928ccd4f7f"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a2a9891723a735d3e2540651184be6fd5b96880c08ffe1a98bae5017e65b544b"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fa2bc0efef3e209a8199fd111b8969fe9db9c711acc46636686331eda7dd4"}, + {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:96619302d4374de5e2345b2b622dc481257a99431277662c30f606f3e22f42be"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:85d5f0c7771dcc7a26c7a27145059b6bb0ce06e4e751ed76cdf123d7039b60b5"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951088d12d339690a92cef2ec5d3cfd957692834c72ffd570ea76a6790222777"}, + {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fb58bcaa343fedc6a9e91f90195b20ccb3135447dc9e4e2570c3a39565853e"}, + {file = "watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1"}, ] [package.dependencies] -MarkupSafe = ">=2.1.1" +anyio = ">=3.0.0" -[package.extras] -watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "websockets" +version = "13.0.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1841c9082a3ba4a05ea824cf6d99570a6a2d8849ef0db16e9c826acb28089e8f"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c5870b4a11b77e4caa3937142b650fbbc0914a3e07a0cf3131f35c0587489c1c"}, + {file = "websockets-13.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d3d1f2eb79fe7b0fb02e599b2bf76a7619c79300fc55f0b5e2d382881d4f7f"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15c7d62ee071fa94a2fc52c2b472fed4af258d43f9030479d9c4a2de885fd543"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6724b554b70d6195ba19650fef5759ef11346f946c07dbbe390e039bcaa7cc3d"}, + {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a952fa2ae57a42ba7951e6b2605e08a24801a4931b5644dfc68939e041bc7f"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17118647c0ea14796364299e942c330d72acc4b248e07e639d34b75067b3cdd8"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a11aae1de4c178fa653b07d90f2fb1a2ed31919a5ea2361a38760192e1858b"}, + {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0617fd0b1d14309c7eab6ba5deae8a7179959861846cbc5cb528a7531c249448"}, + {file = "websockets-13.0.1-cp310-cp310-win32.whl", hash = "sha256:11f9976ecbc530248cf162e359a92f37b7b282de88d1d194f2167b5e7ad80ce3"}, + {file = "websockets-13.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c3c493d0e5141ec055a7d6809a28ac2b88d5b878bb22df8c621ebe79a61123d0"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4"}, + {file = "websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e"}, + {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418"}, + {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df"}, + {file = "websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f"}, + {file = "websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956"}, + {file = "websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c"}, + {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d"}, + {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237"}, + {file = "websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185"}, + {file = "websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231"}, + {file = "websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553"}, + {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7"}, + {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2"}, + {file = "websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb"}, + {file = "websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b74593e9acf18ea5469c3edaa6b27fa7ecf97b30e9dabd5a94c4c940637ab96e"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:132511bfd42e77d152c919147078460c88a795af16b50e42a0bd14f0ad71ddd2"}, + {file = "websockets-13.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:165bedf13556f985a2aa064309baa01462aa79bf6112fbd068ae38993a0e1f1b"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e801ca2f448850685417d723ec70298feff3ce4ff687c6f20922c7474b4746ae"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30d3a1f041360f029765d8704eae606781e673e8918e6b2c792e0775de51352f"}, + {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67648f5e50231b5a7f6d83b32f9c525e319f0ddc841be0de64f24928cd75a603"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4f0426d51c8f0926a4879390f53c7f5a855e42d68df95fff6032c82c888b5f36"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ef48e4137e8799998a343706531e656fdec6797b80efd029117edacb74b0a10a"}, + {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:249aab278810bee585cd0d4de2f08cfd67eed4fc75bde623be163798ed4db2eb"}, + {file = "websockets-13.0.1-cp38-cp38-win32.whl", hash = "sha256:06c0a667e466fcb56a0886d924b5f29a7f0886199102f0a0e1c60a02a3751cb4"}, + {file = "websockets-13.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1f3cf6d6ec1142412d4535adabc6bd72a63f5f148c43fe559f06298bc21953c9"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1fa082ea38d5de51dd409434edc27c0dcbd5fed2b09b9be982deb6f0508d25bc"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4a365bcb7be554e6e1f9f3ed64016e67e2fa03d7b027a33e436aecf194febb63"}, + {file = "websockets-13.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10a0dc7242215d794fb1918f69c6bb235f1f627aaf19e77f05336d147fce7c37"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59197afd478545b1f73367620407b0083303569c5f2d043afe5363676f2697c9"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d20516990d8ad557b5abeb48127b8b779b0b7e6771a265fa3e91767596d7d97"}, + {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1a2e272d067030048e1fe41aa1ec8cfbbaabce733b3d634304fa2b19e5c897f"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad327ac80ba7ee61da85383ca8822ff808ab5ada0e4a030d66703cc025b021c4"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:518f90e6dd089d34eaade01101fd8a990921c3ba18ebbe9b0165b46ebff947f0"}, + {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68264802399aed6fe9652e89761031acc734fc4c653137a5911c2bfa995d6d6d"}, + {file = "websockets-13.0.1-cp39-cp39-win32.whl", hash = "sha256:a5dc0c42ded1557cc7c3f0240b24129aefbad88af4f09346164349391dea8e58"}, + {file = "websockets-13.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b448a0690ef43db5ef31b3a0d9aea79043882b4632cfc3eaab20105edecf6097"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:faef9ec6354fe4f9a2c0bbb52fb1ff852effc897e2a4501e25eb3a47cb0a4f89"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:03d3f9ba172e0a53e37fa4e636b86cc60c3ab2cfee4935e66ed1d7acaa4625ad"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d450f5a7a35662a9b91a64aefa852f0c0308ee256122f5218a42f1d13577d71e"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f55b36d17ac50aa8a171b771e15fbe1561217510c8768af3d546f56c7576cdc"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b9c006cac63772b31abbcd3e3abb6228233eec966bf062e89e7fa7ae0b7333"}, + {file = "websockets-13.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b79915a1179a91f6c5f04ece1e592e2e8a6bd245a0e45d12fd56b2b59e559a32"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f40de079779acbcdbb6ed4c65af9f018f8b77c5ec4e17a4b737c05c2db554491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e4ba642fc87fa532bac07e5ed7e19d56940b6af6a8c61d4429be48718a380f"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a02b0161c43cc9e0232711eff846569fad6ec836a7acab16b3cf97b2344c060"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6aa74a45d4cdc028561a7d6ab3272c8b3018e23723100b12e58be9dfa5a24491"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00fd961943b6c10ee6f0b1130753e50ac5dcd906130dcd77b0003c3ab797d026"}, + {file = "websockets-13.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d93572720d781331fb10d3da9ca1067817d84ad1e7c31466e9f5e59965618096"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:71e6e5a3a3728886caee9ab8752e8113670936a193284be9d6ad2176a137f376"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c4a6343e3b0714e80da0b0893543bf9a5b5fa71b846ae640e56e9abc6fbc4c83"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a678532018e435396e37422a95e3ab87f75028ac79570ad11f5bf23cd2a7d8c"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6716c087e4aa0b9260c4e579bb82e068f84faddb9bfba9906cb87726fa2e870"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e33505534f3f673270dd67f81e73550b11de5b538c56fe04435d63c02c3f26b5"}, + {file = "websockets-13.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acab3539a027a85d568c2573291e864333ec9d912675107d6efceb7e2be5d980"}, + {file = "websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817"}, + {file = "websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e"}, +] [[package]] name = "wrapt" @@ -2344,43 +2410,7 @@ files = [ {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, ] -[[package]] -name = "wtforms" -version = "3.1.2" -description = "Form validation and rendering for Python web development." -optional = false -python-versions = ">=3.8" -files = [ - {file = "wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07"}, - {file = "wtforms-3.1.2.tar.gz", hash = "sha256:f8d76180d7239c94c6322f7990ae1216dae3659b7aa1cee94b6318bdffb474b9"}, -] - -[package.dependencies] -markupsafe = "*" - -[package.extras] -email = ["email-validator"] - -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "15affb779de0b0588c2c7d85bdb8348b6ffffa3279cd7f7e91cc2844ca856f04" +content-hash = "b8c15e41cb84d9b47cfb8752003603a9ad1ef6afae8af6b1bd9287994b1675e4" diff --git a/pyproject.toml b/pyproject.toml index 763d7ce..f990f87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ packages = [{include = "arxiv", from="src"}] [tool.poetry.dependencies] python = "^3.11" -arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", branch = "develop" } +#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", branch = "develop" } backports-datetime-fromisoformat = "*" jsonschema = "*" @@ -32,6 +32,9 @@ unidecode = "*" urllib3 = ">=1.24.2" semver = "^3.0.2" requests-toolbelt = "^1.0.0" +pydantic-settings = "^2.5.2" +fastapi = {extras = ["all"], version = "^0.114.2"} +sqlalchemy = "^2.0.34" [tool.poetry.group.dev.dependencies] coverage = "*" diff --git a/src/arxiv/submit_fastapi/models/__init__.py b/src/arxiv/submit_fastapi/api/__init__.py similarity index 100% rename from src/arxiv/submit_fastapi/models/__init__.py rename to src/arxiv/submit_fastapi/api/__init__.py diff --git a/src/arxiv/submit_fastapi/default_api.py b/src/arxiv/submit_fastapi/api/default_api.py similarity index 55% rename from src/arxiv/submit_fastapi/default_api.py rename to src/arxiv/submit_fastapi/api/default_api.py index 75c9c16..d3977cd 100644 --- a/src/arxiv/submit_fastapi/default_api.py +++ b/src/arxiv/submit_fastapi/api/default_api.py @@ -1,8 +1,6 @@ # coding: utf-8 from typing import Dict, List # noqa: F401 -import importlib -import pkgutil from fastapi import ( # noqa: F401 APIRouter, @@ -19,14 +17,15 @@ status, ) -from .models.extra_models import TokenModel # noqa: F401 -from .models.agreement import Agreement +from arxiv.submit_fastapi.config import config +from arxiv.submit_fastapi.api.models.extra_models import TokenModel # noqa: F401 +from arxiv.submit_fastapi.api.models.agreement import Agreement +implementation = config.submission_api_implementation() +impl_depends = config.submission_api_implementation_depends_function router = APIRouter() -BaseDefaultApi = None - @router.get( "/status", responses={ @@ -36,45 +35,38 @@ tags=["default"], response_model_by_alias=True, ) -async def get_service_status( -) -> None: +async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: """Get information about the current status of file management service.""" - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().get_service_status() - - -@router.get( - "/{submission_id}", - responses={ - 200: {"model": object, "description": "The submission data."}, - }, - tags=["default"], - response_model_by_alias=True, -) -async def get_submission( - submission_id: str = Path(..., description="Id of the submission to get."), -) -> object: - """Get information about a submission.""" - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().get_submission(submission_id) - - -@router.post( - "/", - responses={ - 200: {"model": str, "description": "Successfully started a new submission."}, - }, - tags=["default"], - response_model_by_alias=True, -) -async def new( -) -> str: - """Start a submission and get a submission ID.""" - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().new() + return await implementation.get_service_status(impl_dep) + + +# @router.get( +# "/{submission_id}", +# responses={ +# 200: {"model": object, "description": "The submission data."}, +# }, +# tags=["default"], +# response_model_by_alias=True, +# ) +# async def get_submission( +# submission_id: str = Path(..., description="Id of the submission to get."), +# ) -> object: +# """Get information about a submission.""" +# return await implementation.get_submission(submission_id) + + +# @router.post( +# "/", +# responses={ +# 200: {"model": str, "description": "Successfully started a new submission."}, +# }, +# tags=["default"], +# response_model_by_alias=True, +# ) +# async def new( +# ) -> str: +# """Start a submission and get a submission ID.""" +# return await implementation.new() @router.post( @@ -92,11 +84,10 @@ async def new( async def submission_id_accept_policy_post( submission_id: str = Path(..., description="Id of the submission to get."), agreement: Agreement = Body(None, description=""), + impl_dep: dict = Depends(impl_depends), ) -> object: - """Agree to a an arXiv policy to initiate a new item submission or a change to an existing item. """ - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().submission_id_accept_policy_post(submission_id, agreement) + """Agree to an arXiv policy to initiate a new item submission or a change to an existing item. """ + return await implementation.submission_id_accept_policy_post(impl_dep, submission_id, agreement) @router.post( @@ -109,11 +100,10 @@ async def submission_id_accept_policy_post( ) async def submission_id_deposited_post( submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), ) -> None: """The submission has been successfully deposited by an external service.""" - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().submission_id_deposited_post(submission_id) + return await implementation.submission_id_deposited_post(impl_dep, submission_id) @router.post( @@ -126,11 +116,10 @@ async def submission_id_deposited_post( ) async def submission_id_mark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), ) -> None: """Mark that the submission is being processed for deposit.""" - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().submission_id_mark_processing_for_deposit_post(submission_id) + return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, submission_id) @router.post( @@ -143,8 +132,7 @@ async def submission_id_mark_processing_for_deposit_post( ) async def submission_id_unmark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), ) -> None: """Indicate that an external system in no longer working on depositing this submission. This does not indicate that is was successfully deposited. """ - if not BaseDefaultApi.subclasses: - raise HTTPException(status_code=500, detail="Not implemented") - return await BaseDefaultApi.subclasses[0]().submission_id_unmark_processing_for_deposit_post(submission_id) + return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, submission_id) diff --git a/src/arxiv/submit_fastapi/api/default_api_base.py b/src/arxiv/submit_fastapi/api/default_api_base.py new file mode 100644 index 0000000..11b5652 --- /dev/null +++ b/src/arxiv/submit_fastapi/api/default_api_base.py @@ -0,0 +1,64 @@ +# coding: utf-8 +from abc import ABC, abstractmethod + +from typing import ClassVar, Dict, List, Tuple # noqa: F401 + +from .models.agreement import Agreement + + +class BaseDefaultApi(ABC): + + @abstractmethod + async def get_submission( + self, + impl_data: Dict, + submission_id: str, + ) -> object: + """Get information about a ui-app.""" + ... + + @abstractmethod + async def new( + self, + impl_data: Dict, + + ) -> str: + """Start a ui-app and get a ui-app ID.""" + ... + + @abstractmethod + async def submission_id_accept_policy_post( + self, + impl_data: Dict, + submission_id: str, + agreement: Agreement, + ) -> object: + """Agree to an arXiv policy to initiate a new item ui-app or a change to an existing item. """ + ... + + @abstractmethod + async def submission_id_deposited_post( + self, + impl_data: Dict, + submission_id: str, + ) -> None: + """The ui-app has been successfully deposited by an external service.""" + ... + + @abstractmethod + async def submission_id_mark_processing_for_deposit_post( + self, + impl_data: Dict, + submission_id: str, + ) -> None: + """Mark that the ui-app is being processed for deposit.""" + ... + + @abstractmethod + async def submission_id_unmark_processing_for_deposit_post( + self, + impl_data: Dict, + submission_id: str, + ) -> None: + """Indicate that an external system in no longer working on depositing this ui-app. This does not indicate that is was successfully deposited. """ + ... diff --git a/src/arxiv/submit_fastapi/api/legacy_implementation.py b/src/arxiv/submit_fastapi/api/legacy_implementation.py new file mode 100644 index 0000000..2480423 --- /dev/null +++ b/src/arxiv/submit_fastapi/api/legacy_implementation.py @@ -0,0 +1,40 @@ +from typing import Dict + +from fastapi import Depends + +from .default_api_base import BaseDefaultApi +import logging + +from .models.agreement import Agreement +from ..db import get_db + +logger = logging.getLogger(__name__) + + +def legacy_depends(db=Depends(get_db)) -> dict: + return {"db": db} + + +class LegacySubmitImplementation(BaseDefaultApi): + + async def get_submission(self, impl_data: Dict, submission_id: str) -> object: + pass + + async def new(self, impl_data: Dict) -> str: + pass + + async def submission_id_accept_policy_post(self, impl_data: Dict, submission_id: str, + agreement: Agreement) -> object: + pass + + async def submission_id_deposited_post(self, impl_data: Dict, submission_id: str) -> None: + pass + + async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, submission_id: str) -> None: + pass + + async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, submission_id: str) -> None: + pass + + async def get_service_status(self, impl_data: dict): + return f"{self.__class__.__name__} impl_data: {impl_data}" \ No newline at end of file diff --git a/src/arxiv/submit_fastapi/api/models/__init__.py b/src/arxiv/submit_fastapi/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/arxiv/submit_fastapi/models/agreement.py b/src/arxiv/submit_fastapi/api/models/agreement.py similarity index 100% rename from src/arxiv/submit_fastapi/models/agreement.py rename to src/arxiv/submit_fastapi/api/models/agreement.py diff --git a/src/arxiv/submit_fastapi/models/extra_models.py b/src/arxiv/submit_fastapi/api/models/extra_models.py similarity index 100% rename from src/arxiv/submit_fastapi/models/extra_models.py rename to src/arxiv/submit_fastapi/api/models/extra_models.py diff --git a/src/arxiv/submit_fastapi/app.py b/src/arxiv/submit_fastapi/app.py index 509f1e3..c101fc5 100644 --- a/src/arxiv/submit_fastapi/app.py +++ b/src/arxiv/submit_fastapi/app.py @@ -1,10 +1,10 @@ from fastapi import FastAPI -from .default_api import router as DefaultApiRouter - +from arxiv.submit_fastapi.api.default_api import router as DefaultApiRouter +from .config import config app = FastAPI( title="arxiv submit", description="No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)", version="0.1", ) - +app.state.config = config app.include_router(DefaultApiRouter) diff --git a/src/arxiv/submit_fastapi/config.py b/src/arxiv/submit_fastapi/config.py new file mode 100644 index 0000000..54435c0 --- /dev/null +++ b/src/arxiv/submit_fastapi/config.py @@ -0,0 +1,34 @@ +import secrets +from typing import List, Union + +from pydantic_settings import BaseSettings + +from pydantic import SecretStr, PyObject + + + +class Settings(BaseSettings): + classic_db_uri: str = 'mysql://not-set-check-config/0000' + """arXiv legacy DB URL.""" + + jwt_secret: SecretStr = "not-set-" + secrets.token_urlsafe(16) + """NG JWT_SECRET from arxiv-auth login service""" + + submission_api_implementation: PyObject = 'arxiv.submit_fastapi.api.legacy_implementation.LegacySubmitImplementation' + """Class to use for submission API implementation.""" + + submission_api_implementation_depends_function: PyObject = 'arxiv.submit_fastapi.api.legacy_implementation.legacy_depends' + """Function to depend on submission API implementation.""" + + + class Config: + env_file = "env" + """File to read environment from""" + + case_sensitive = False + + +config = Settings() +"""Settings build from defaults, env file, and env vars. + +Environment vars have the highest precedence, defaults the lowest.""" diff --git a/src/arxiv/submit_fastapi/db.py b/src/arxiv/submit_fastapi/db.py new file mode 100644 index 0000000..1ae4717 --- /dev/null +++ b/src/arxiv/submit_fastapi/db.py @@ -0,0 +1,32 @@ +from fastapi import Depends +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +_sessionLocal = sessionmaker(autocommit=False, autoflush=False) + + +def get_sessionlocal(): + global _sessionLocal + if _sessionLocal is None: + from .config import config + if 'sqlite' in config.classic_db_uri: + args = {"check_same_thread": False} + else: + args = {} + engine = create_engine(config.classic_db_uri, echo=config.echo_sql, connect_args=args) + _sessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + return _sessionLocal + + +def get_db(session_local=Depends(get_sessionlocal)): + """Dependency for fastapi routes""" + with session_local() as session: + try: + yield session + if session.new or session.dirty or session.deleted: + session.commit() + except Exception: + session.rollback() + raise + diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 8eba8ca..ac896c5 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient -from arxiv.submit_fastapi.models.agreement import Agreement # noqa: F401 +from arxiv.submit_fastapi.api.models.agreement import Agreement # noqa: F401 def test_get_service_status(client: TestClient): From 6d750b5e473e8cdb65a71597dcffbb757095065e Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 17 Sep 2024 10:53:31 -0400 Subject: [PATCH 07/28] Moves src/arxiv/submit_fastapi to ce_submit/submit_fastapi This is to avoid conflicts with the arxiv. package from arxiv-base There is a way to resolve this conflict but it has just caused hassles during NG. So let's move away from that pattern. --- src/arxiv/submission/__init__.py | 257 ---- src/arxiv/submission/auth.py | 46 - src/arxiv/submission/config.py | 299 ---- src/arxiv/submission/core.py | 201 --- src/arxiv/submission/domain/__init__.py | 12 - src/arxiv/submission/domain/agent.py | 142 -- src/arxiv/submission/domain/annotation.py | 115 -- src/arxiv/submission/domain/compilation.py | 166 -- src/arxiv/submission/domain/event/__init__.py | 1354 ----------------- src/arxiv/submission/domain/event/base.py | 353 ----- src/arxiv/submission/domain/event/flag.py | 256 ---- src/arxiv/submission/domain/event/process.py | 51 - src/arxiv/submission/domain/event/proposal.py | 148 -- src/arxiv/submission/domain/event/request.py | 224 --- .../event/tests/test_abstract_cleanup.py | 52 - .../event/tests/test_event_construction.py | 45 - .../domain/event/tests/test_hooks.py | 61 - src/arxiv/submission/domain/event/util.py | 27 - .../submission/domain/event/validators.py | 128 -- .../domain/event/versioning/__init__.py | 131 -- .../domain/event/versioning/_base.py | 121 -- .../domain/event/versioning/tests/__init__.py | 1 - .../event/versioning/tests/test_example.py | 15 - .../event/versioning/tests/test_versioning.py | 136 -- .../event/versioning/version_0_0_0_example.py | 46 - src/arxiv/submission/domain/flag.py | 100 -- src/arxiv/submission/domain/meta.py | 20 - src/arxiv/submission/domain/preview.py | 24 - src/arxiv/submission/domain/process.py | 48 - src/arxiv/submission/domain/proposal.py | 65 - src/arxiv/submission/domain/submission.py | 534 ------- src/arxiv/submission/domain/tests/__init__.py | 1 - .../submission/domain/tests/test_events.py | 1016 ------------- src/arxiv/submission/domain/uploads.py | 153 -- src/arxiv/submission/domain/util.py | 19 - src/arxiv/submission/exceptions.py | 28 - src/arxiv/submission/process/__init__.py | 2 - .../submission/process/process_source.py | 504 ------ src/arxiv/submission/process/tests.py | 537 ------- src/arxiv/submission/schedule.py | 82 - src/arxiv/submission/serializer.py | 97 -- src/arxiv/submission/services/__init__.py | 8 - .../submission/services/classic/__init__.py | 719 --------- .../submission/services/classic/bootstrap.py | 155 -- .../submission/services/classic/event.py | 76 - .../submission/services/classic/exceptions.py | 21 - .../services/classic/interpolate.py | 304 ---- src/arxiv/submission/services/classic/load.py | 226 --- src/arxiv/submission/services/classic/log.py | 141 -- .../submission/services/classic/models.py | 909 ----------- .../submission/services/classic/patch.py | 122 -- .../submission/services/classic/proposal.py | 64 - .../services/classic/tests/__init__.py | 11 - .../services/classic/tests/test_admin_log.py | 97 -- .../classic/tests/test_get_licenses.py | 45 - .../classic/tests/test_get_submission.py | 248 --- .../classic/tests/test_store_annotations.py | 1 - .../classic/tests/test_store_event.py | 318 ---- .../classic/tests/test_store_proposals.py | 139 -- .../submission/services/classic/tests/util.py | 24 - src/arxiv/submission/services/classic/util.py | 115 -- .../services/classifier/__init__.py | 16 - .../services/classifier/classifier.py | 108 -- .../services/classifier/tests/__init__.py | 1 - .../classifier/tests/data/linenos.json | 1 - .../tests/data/sampleFailedCyrillic.json | 21 - .../classifier/tests/data/sampleResponse.json | 74 - .../services/classifier/tests/tests.py | 228 --- .../submission/services/compiler/__init__.py | 3 - .../submission/services/compiler/compiler.py | 249 --- .../submission/services/compiler/tests.py | 237 --- .../services/filemanager/__init__.py | 3 - .../services/filemanager/filemanager.py | 336 ---- .../services/filemanager/tests/__init__.py | 1 - .../services/filemanager/tests/data/test.txt | 9 - .../services/filemanager/tests/data/test.zip | Bin 896 -> 0 bytes .../tests/test_filemanager_integration.py | 255 ---- .../submission/services/plaintext/__init__.py | 3 - .../services/plaintext/plaintext.py | 167 -- .../submission/services/plaintext/tests.py | 827 ---------- .../submission/services/preview/__init__.py | 3 - .../submission/services/preview/preview.py | 218 --- .../submission/services/preview/tests.py | 142 -- .../submission/services/stream/__init__.py | 3 - .../submission/services/stream/stream.py | 128 -- src/arxiv/submission/services/util.py | 47 - .../submission-core/confirmation-email.html | 28 - .../submission-core/confirmation-email.txt | 38 - src/arxiv/submission/tests/#util.py# | 74 - src/arxiv/submission/tests/__init__.py | 1 - src/arxiv/submission/tests/api/test_api.py | 183 --- .../tests/classic/test_classic_integration.py | 1064 ------------- .../submission/tests/examples/__init__.py | 7 - .../examples/test_01_working_submission.py | 180 --- .../examples/test_02_finalized_submission.py | 200 --- .../examples/test_03_on_hold_submission.py | 205 --- .../examples/test_04_published_submission.py | 531 ------- .../examples/test_05_working_replacement.py | 465 ------ .../test_06_second_version_published.py | 420 ----- .../examples/test_07_cross_list_requested.py | 1086 ------------- .../examples/test_10_abandon_submission.py | 682 --------- .../tests/schedule/test_schedule.py | 62 - .../tests/serializer/test_serializer.py | 151 -- src/arxiv/submission/tests/util.py | 74 - .../submit_fastapi/api/models/__init__.py | 0 src/arxiv/submit_fastapi/config.py | 34 - submit/.python-version | 1 + submit/__init__.py | 1 + submit/config.py | 386 +++++ {src/arxiv => submit/controllers}/__init__.py | 0 .../controllers/api}/__init__.py | 0 submit/controllers/ui/__init__.py | 55 + submit/controllers/ui/cross.py | 211 +++ submit/controllers/ui/delete.py | 133 ++ submit/controllers/ui/jref.py | 150 ++ .../controllers/ui/new}/__init__.py | 0 submit/controllers/ui/new/authorship.py | 98 ++ submit/controllers/ui/new/classification.py | 212 +++ submit/controllers/ui/new/create.py | 105 ++ submit/controllers/ui/new/final.py | 83 + submit/controllers/ui/new/license.py | 76 + submit/controllers/ui/new/metadata.py | 247 +++ submit/controllers/ui/new/policy.py | 68 + submit/controllers/ui/new/process.py | 257 ++++ submit/controllers/ui/new/reasons.py | 37 + .../ui/new/tests/test_authorship.py | 152 ++ .../ui/new/tests/test_classification.py | 268 ++++ .../controllers/ui/new/tests/test_license.py | 165 ++ .../controllers/ui/new/tests/test_metadata.py | 405 +++++ .../controllers/ui/new/tests/test_policy.py | 176 +++ .../controllers/ui/new/tests/test_primary.py | 155 ++ .../controllers/ui/new/tests/test_unsubmit.py | 101 ++ .../controllers/ui/new/tests/test_upload.py | 334 ++++ .../ui/new/tests/test_verify_user.py | 128 ++ submit/controllers/ui/new/unsubmit.py | 59 + submit/controllers/ui/new/upload.py | 548 +++++++ submit/controllers/ui/new/upload_delete.py | 251 +++ submit/controllers/ui/new/verify_user.py | 82 + submit/controllers/ui/tests/__init__.py | 1 + submit/controllers/ui/tests/test_jref.py | 147 ++ submit/controllers/ui/util.py | 173 +++ submit/controllers/ui/withdraw.py | 88 ++ submit/db.sqlite | Bin 0 -> 327680 bytes submit/factory.py | 101 ++ submit/filters/__init__.py | 141 ++ submit/filters/tests/test_tex_filters.py | 143 ++ submit/filters/tex_filters.py | 548 +++++++ submit/integration/README.md | 19 + .../api => submit/integration}/__init__.py | 0 submit/integration/test_integration.py | 322 ++++ submit/integration/upload2.tar.gz | Bin 0 -> 27805 bytes submit/routes/__init__.py | 3 + .../classic => submit/routes/api}/__init__.py | 0 submit/routes/auth.py | 24 + submit/routes/ui/__init__.py | 1 + submit/routes/ui/flow_control.py | 290 ++++ submit/routes/ui/ui.py | 558 +++++++ submit/services/__init__.py | 2 + submit/static/css/manage_submissions.css | 89 ++ submit/static/css/manage_submissions.css.map | 7 + submit/static/css/submit.css | 551 +++++++ submit/static/css/submit.css.map | 1 + .../images/github_issues_search_box.png | Bin 0 -> 17739 bytes submit/static/js/authorship.js | 20 + submit/static/js/filewidget.js | 20 + submit/static/sass/manage_submissions.sass | 112 ++ submit/static/sass/submit.sass | 440 ++++++ submit/templates/submit/add_metadata.html | 119 ++ .../submit/add_optional_metadata.html | 165 ++ submit/templates/submit/admin_macros.html | 76 + submit/templates/submit/authorship.html | 66 + submit/templates/submit/base.html | 61 + submit/templates/submit/classification.html | 94 ++ .../submit/confirm_cancel_request.html | 36 + submit/templates/submit/confirm_delete.html | 30 + .../templates/submit/confirm_delete_all.html | 27 + .../submit/confirm_delete_submission.html | 40 + submit/templates/submit/confirm_submit.html | 26 + submit/templates/submit/confirm_unsubmit.html | 59 + submit/templates/submit/cross_list.html | 89 ++ submit/templates/submit/error_messages.html | 104 ++ submit/templates/submit/file_process.html | 281 ++++ submit/templates/submit/file_upload.html | 178 +++ submit/templates/submit/final_preview.html | 92 ++ submit/templates/submit/jref.html | 161 ++ submit/templates/submit/license.html | 72 + .../templates/submit/manage_submissions.html | 175 +++ submit/templates/submit/policy.html | 78 + submit/templates/submit/replace.html | 36 + .../templates/submit/request_cross_list.html | 121 ++ submit/templates/submit/status.html | 24 + submit/templates/submit/submit_macros.html | 41 + submit/templates/submit/testalerts.html | 1 + submit/templates/submit/tex-log-test.html | 84 + submit/templates/submit/verify_user.html | 97 ++ submit/templates/submit/withdraw.html | 124 ++ submit/tests/__init__.py | 1 + submit/tests/csrf_util.py | 24 + submit/tests/mock_filemanager.py | 109 ++ submit/tests/test_domain.py | 65 + submit/tests/test_workflow.py | 762 ++++++++++ submit/util.py | 157 ++ submit/workflow/__init__.py | 150 ++ submit/workflow/conditions.py | 72 + submit/workflow/processor.py | 81 + submit/workflow/stages.py | 161 ++ submit/workflow/test_new_submission.py | 258 ++++ .../tests/schedule => submit_ce}/__init__.py | 0 .../submit_fastapi}/__init__.py | 0 .../submit_fastapi/api}/__init__.py | 0 .../submit_fastapi/api/default_api.py | 107 +- .../submit_fastapi/api/default_api_base.py | 2 +- .../submit_fastapi/api/models}/__init__.py | 0 .../submit_fastapi/api/models/agreement.py | 9 +- .../submit_fastapi/api/models/event_info.py | 21 + .../submit_fastapi/api/models/extra_models.py | 0 .../arxiv => submit_ce}/submit_fastapi/app.py | 8 +- submit_ce/submit_fastapi/config.py | 22 + {src/arxiv => submit_ce}/submit_fastapi/db.py | 2 +- .../implementations/__init__.py | 13 + .../implementations}/legacy_implementation.py | 29 +- .../submit_fastapi/main.py | 0 tests/conftest.py | 2 +- tests/test_default_api.py | 2 +- 224 files changed, 13300 insertions(+), 19984 deletions(-) delete mode 100644 src/arxiv/submission/__init__.py delete mode 100644 src/arxiv/submission/auth.py delete mode 100644 src/arxiv/submission/config.py delete mode 100644 src/arxiv/submission/core.py delete mode 100644 src/arxiv/submission/domain/__init__.py delete mode 100644 src/arxiv/submission/domain/agent.py delete mode 100644 src/arxiv/submission/domain/annotation.py delete mode 100644 src/arxiv/submission/domain/compilation.py delete mode 100644 src/arxiv/submission/domain/event/__init__.py delete mode 100644 src/arxiv/submission/domain/event/base.py delete mode 100644 src/arxiv/submission/domain/event/flag.py delete mode 100644 src/arxiv/submission/domain/event/process.py delete mode 100644 src/arxiv/submission/domain/event/proposal.py delete mode 100644 src/arxiv/submission/domain/event/request.py delete mode 100644 src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py delete mode 100644 src/arxiv/submission/domain/event/tests/test_event_construction.py delete mode 100644 src/arxiv/submission/domain/event/tests/test_hooks.py delete mode 100644 src/arxiv/submission/domain/event/util.py delete mode 100644 src/arxiv/submission/domain/event/validators.py delete mode 100644 src/arxiv/submission/domain/event/versioning/__init__.py delete mode 100644 src/arxiv/submission/domain/event/versioning/_base.py delete mode 100644 src/arxiv/submission/domain/event/versioning/tests/__init__.py delete mode 100644 src/arxiv/submission/domain/event/versioning/tests/test_example.py delete mode 100644 src/arxiv/submission/domain/event/versioning/tests/test_versioning.py delete mode 100644 src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py delete mode 100644 src/arxiv/submission/domain/flag.py delete mode 100644 src/arxiv/submission/domain/meta.py delete mode 100644 src/arxiv/submission/domain/preview.py delete mode 100644 src/arxiv/submission/domain/process.py delete mode 100644 src/arxiv/submission/domain/proposal.py delete mode 100644 src/arxiv/submission/domain/submission.py delete mode 100644 src/arxiv/submission/domain/tests/__init__.py delete mode 100644 src/arxiv/submission/domain/tests/test_events.py delete mode 100644 src/arxiv/submission/domain/uploads.py delete mode 100644 src/arxiv/submission/domain/util.py delete mode 100644 src/arxiv/submission/exceptions.py delete mode 100644 src/arxiv/submission/process/__init__.py delete mode 100644 src/arxiv/submission/process/process_source.py delete mode 100644 src/arxiv/submission/process/tests.py delete mode 100644 src/arxiv/submission/schedule.py delete mode 100644 src/arxiv/submission/serializer.py delete mode 100644 src/arxiv/submission/services/__init__.py delete mode 100644 src/arxiv/submission/services/classic/__init__.py delete mode 100644 src/arxiv/submission/services/classic/bootstrap.py delete mode 100644 src/arxiv/submission/services/classic/event.py delete mode 100644 src/arxiv/submission/services/classic/exceptions.py delete mode 100644 src/arxiv/submission/services/classic/interpolate.py delete mode 100644 src/arxiv/submission/services/classic/load.py delete mode 100644 src/arxiv/submission/services/classic/log.py delete mode 100644 src/arxiv/submission/services/classic/models.py delete mode 100644 src/arxiv/submission/services/classic/patch.py delete mode 100644 src/arxiv/submission/services/classic/proposal.py delete mode 100644 src/arxiv/submission/services/classic/tests/__init__.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_admin_log.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_get_licenses.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_get_submission.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_store_annotations.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_store_event.py delete mode 100644 src/arxiv/submission/services/classic/tests/test_store_proposals.py delete mode 100644 src/arxiv/submission/services/classic/tests/util.py delete mode 100644 src/arxiv/submission/services/classic/util.py delete mode 100644 src/arxiv/submission/services/classifier/__init__.py delete mode 100644 src/arxiv/submission/services/classifier/classifier.py delete mode 100644 src/arxiv/submission/services/classifier/tests/__init__.py delete mode 100644 src/arxiv/submission/services/classifier/tests/data/linenos.json delete mode 100644 src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json delete mode 100644 src/arxiv/submission/services/classifier/tests/data/sampleResponse.json delete mode 100644 src/arxiv/submission/services/classifier/tests/tests.py delete mode 100644 src/arxiv/submission/services/compiler/__init__.py delete mode 100644 src/arxiv/submission/services/compiler/compiler.py delete mode 100644 src/arxiv/submission/services/compiler/tests.py delete mode 100644 src/arxiv/submission/services/filemanager/__init__.py delete mode 100644 src/arxiv/submission/services/filemanager/filemanager.py delete mode 100644 src/arxiv/submission/services/filemanager/tests/__init__.py delete mode 100644 src/arxiv/submission/services/filemanager/tests/data/test.txt delete mode 100644 src/arxiv/submission/services/filemanager/tests/data/test.zip delete mode 100644 src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py delete mode 100644 src/arxiv/submission/services/plaintext/__init__.py delete mode 100644 src/arxiv/submission/services/plaintext/plaintext.py delete mode 100644 src/arxiv/submission/services/plaintext/tests.py delete mode 100644 src/arxiv/submission/services/preview/__init__.py delete mode 100644 src/arxiv/submission/services/preview/preview.py delete mode 100644 src/arxiv/submission/services/preview/tests.py delete mode 100644 src/arxiv/submission/services/stream/__init__.py delete mode 100644 src/arxiv/submission/services/stream/stream.py delete mode 100644 src/arxiv/submission/services/util.py delete mode 100644 src/arxiv/submission/templates/submission-core/confirmation-email.html delete mode 100644 src/arxiv/submission/templates/submission-core/confirmation-email.txt delete mode 100644 src/arxiv/submission/tests/#util.py# delete mode 100644 src/arxiv/submission/tests/__init__.py delete mode 100644 src/arxiv/submission/tests/api/test_api.py delete mode 100644 src/arxiv/submission/tests/classic/test_classic_integration.py delete mode 100644 src/arxiv/submission/tests/examples/__init__.py delete mode 100644 src/arxiv/submission/tests/examples/test_01_working_submission.py delete mode 100644 src/arxiv/submission/tests/examples/test_02_finalized_submission.py delete mode 100644 src/arxiv/submission/tests/examples/test_03_on_hold_submission.py delete mode 100644 src/arxiv/submission/tests/examples/test_04_published_submission.py delete mode 100644 src/arxiv/submission/tests/examples/test_05_working_replacement.py delete mode 100644 src/arxiv/submission/tests/examples/test_06_second_version_published.py delete mode 100644 src/arxiv/submission/tests/examples/test_07_cross_list_requested.py delete mode 100644 src/arxiv/submission/tests/examples/test_10_abandon_submission.py delete mode 100644 src/arxiv/submission/tests/schedule/test_schedule.py delete mode 100644 src/arxiv/submission/tests/serializer/test_serializer.py delete mode 100644 src/arxiv/submission/tests/util.py delete mode 100644 src/arxiv/submit_fastapi/api/models/__init__.py delete mode 100644 src/arxiv/submit_fastapi/config.py create mode 100644 submit/.python-version create mode 100644 submit/__init__.py create mode 100644 submit/config.py rename {src/arxiv => submit/controllers}/__init__.py (100%) rename {src/arxiv/submission/domain/event/tests => submit/controllers/api}/__init__.py (100%) create mode 100644 submit/controllers/ui/__init__.py create mode 100644 submit/controllers/ui/cross.py create mode 100644 submit/controllers/ui/delete.py create mode 100644 submit/controllers/ui/jref.py rename {src/arxiv/submission/tests/annotations => submit/controllers/ui/new}/__init__.py (100%) create mode 100644 submit/controllers/ui/new/authorship.py create mode 100644 submit/controllers/ui/new/classification.py create mode 100644 submit/controllers/ui/new/create.py create mode 100644 submit/controllers/ui/new/final.py create mode 100644 submit/controllers/ui/new/license.py create mode 100644 submit/controllers/ui/new/metadata.py create mode 100644 submit/controllers/ui/new/policy.py create mode 100644 submit/controllers/ui/new/process.py create mode 100644 submit/controllers/ui/new/reasons.py create mode 100644 submit/controllers/ui/new/tests/test_authorship.py create mode 100644 submit/controllers/ui/new/tests/test_classification.py create mode 100644 submit/controllers/ui/new/tests/test_license.py create mode 100644 submit/controllers/ui/new/tests/test_metadata.py create mode 100644 submit/controllers/ui/new/tests/test_policy.py create mode 100644 submit/controllers/ui/new/tests/test_primary.py create mode 100644 submit/controllers/ui/new/tests/test_unsubmit.py create mode 100644 submit/controllers/ui/new/tests/test_upload.py create mode 100644 submit/controllers/ui/new/tests/test_verify_user.py create mode 100644 submit/controllers/ui/new/unsubmit.py create mode 100644 submit/controllers/ui/new/upload.py create mode 100644 submit/controllers/ui/new/upload_delete.py create mode 100644 submit/controllers/ui/new/verify_user.py create mode 100644 submit/controllers/ui/tests/__init__.py create mode 100644 submit/controllers/ui/tests/test_jref.py create mode 100644 submit/controllers/ui/util.py create mode 100644 submit/controllers/ui/withdraw.py create mode 100644 submit/db.sqlite create mode 100644 submit/factory.py create mode 100644 submit/filters/__init__.py create mode 100644 submit/filters/tests/test_tex_filters.py create mode 100644 submit/filters/tex_filters.py create mode 100644 submit/integration/README.md rename {src/arxiv/submission/tests/api => submit/integration}/__init__.py (100%) create mode 100644 submit/integration/test_integration.py create mode 100644 submit/integration/upload2.tar.gz create mode 100644 submit/routes/__init__.py rename {src/arxiv/submission/tests/classic => submit/routes/api}/__init__.py (100%) create mode 100644 submit/routes/auth.py create mode 100644 submit/routes/ui/__init__.py create mode 100644 submit/routes/ui/flow_control.py create mode 100644 submit/routes/ui/ui.py create mode 100644 submit/services/__init__.py create mode 100644 submit/static/css/manage_submissions.css create mode 100644 submit/static/css/manage_submissions.css.map create mode 100644 submit/static/css/submit.css create mode 100644 submit/static/css/submit.css.map create mode 100644 submit/static/images/github_issues_search_box.png create mode 100644 submit/static/js/authorship.js create mode 100644 submit/static/js/filewidget.js create mode 100644 submit/static/sass/manage_submissions.sass create mode 100644 submit/static/sass/submit.sass create mode 100644 submit/templates/submit/add_metadata.html create mode 100644 submit/templates/submit/add_optional_metadata.html create mode 100644 submit/templates/submit/admin_macros.html create mode 100644 submit/templates/submit/authorship.html create mode 100644 submit/templates/submit/base.html create mode 100644 submit/templates/submit/classification.html create mode 100644 submit/templates/submit/confirm_cancel_request.html create mode 100644 submit/templates/submit/confirm_delete.html create mode 100644 submit/templates/submit/confirm_delete_all.html create mode 100644 submit/templates/submit/confirm_delete_submission.html create mode 100644 submit/templates/submit/confirm_submit.html create mode 100644 submit/templates/submit/confirm_unsubmit.html create mode 100644 submit/templates/submit/cross_list.html create mode 100644 submit/templates/submit/error_messages.html create mode 100644 submit/templates/submit/file_process.html create mode 100644 submit/templates/submit/file_upload.html create mode 100644 submit/templates/submit/final_preview.html create mode 100644 submit/templates/submit/jref.html create mode 100644 submit/templates/submit/license.html create mode 100644 submit/templates/submit/manage_submissions.html create mode 100644 submit/templates/submit/policy.html create mode 100644 submit/templates/submit/replace.html create mode 100644 submit/templates/submit/request_cross_list.html create mode 100644 submit/templates/submit/status.html create mode 100644 submit/templates/submit/submit_macros.html create mode 100644 submit/templates/submit/testalerts.html create mode 100644 submit/templates/submit/tex-log-test.html create mode 100644 submit/templates/submit/verify_user.html create mode 100644 submit/templates/submit/withdraw.html create mode 100644 submit/tests/__init__.py create mode 100644 submit/tests/csrf_util.py create mode 100644 submit/tests/mock_filemanager.py create mode 100644 submit/tests/test_domain.py create mode 100644 submit/tests/test_workflow.py create mode 100644 submit/util.py create mode 100644 submit/workflow/__init__.py create mode 100644 submit/workflow/conditions.py create mode 100644 submit/workflow/processor.py create mode 100644 submit/workflow/stages.py create mode 100644 submit/workflow/test_new_submission.py rename {src/arxiv/submission/tests/schedule => submit_ce}/__init__.py (100%) rename {src/arxiv/submission/tests/serializer => submit_ce/submit_fastapi}/__init__.py (100%) rename {src/arxiv/submit_fastapi => submit_ce/submit_fastapi/api}/__init__.py (100%) rename {src/arxiv => submit_ce}/submit_fastapi/api/default_api.py (66%) rename {src/arxiv => submit_ce}/submit_fastapi/api/default_api_base.py (98%) rename {src/arxiv/submit_fastapi/api => submit_ce/submit_fastapi/api/models}/__init__.py (100%) rename {src/arxiv => submit_ce}/submit_fastapi/api/models/agreement.py (94%) create mode 100644 submit_ce/submit_fastapi/api/models/event_info.py rename {src/arxiv => submit_ce}/submit_fastapi/api/models/extra_models.py (100%) rename {src/arxiv => submit_ce}/submit_fastapi/app.py (56%) create mode 100644 submit_ce/submit_fastapi/config.py rename {src/arxiv => submit_ce}/submit_fastapi/db.py (93%) create mode 100644 submit_ce/submit_fastapi/implementations/__init__.py rename {src/arxiv/submit_fastapi/api => submit_ce/submit_fastapi/implementations}/legacy_implementation.py (55%) rename {src/arxiv => submit_ce}/submit_fastapi/main.py (100%) diff --git a/src/arxiv/submission/__init__.py b/src/arxiv/submission/__init__.py deleted file mode 100644 index fb8aa2b..0000000 --- a/src/arxiv/submission/__init__.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Core event-centric data abstraction for the submission & moderation subsystem. - -This package provides an event-based API for mutating submissions. Instead of -representing submissions as objects and mutating them directly in web -controllers and other places, we represent a submission as a stream of commands -or events. This ensures that we have a precise and complete record of -activities concerning submissions, and provides an explicit and consistent -definition of operations that can be performed within the arXiv submission -system. - -Overview -======== - -Event types are defined in :mod:`.domain.event`. The base class for all events -is :class:`.domain.event.base.Event`. Each event type defines additional -required data, and have ``validate`` and ``project`` methods that implement its -logic. Events operate on :class:`.domain.submission.Submission` instances. - -.. code-block:: python - - from arxiv.submission import CreateSubmission, User, Submission - user = User(1345, 'foo@user.com') - creation = CreateSubmission(creator=user) - - -:mod:`.core` defines the persistence API for submission data. -:func:`.core.save` is used to commit new events. :func:`.core.load` retrieves -events for a submission and plays them forward to get the current state, -whereas :func:`.core.load_fast` retrieves the latest projected state of the -submission (faster, theoretically less reliable). - -.. code-block:: python - - from arxiv.submission import save, SetTitle - submission, events = save(creation, SetTitle(creator=user, title='Title!')) - - -Watch out for :class:`.exceptions.InvalidEvent` to catch validation-related -problems (e.g. bad data, submission in wrong state). Watch for -:class:`.SaveError` to catch problems with persisting events. - -Callbacks can be attached to event types in order to execute routines -automatically when specific events are committed, using -:func:`.domain.Event.bind`. - -.. code-block:: python - - from typing import Iterable - - @SetTitle.bind() - def flip_title(event: SetTitle, before: Submissionm, after: Submission, - creator: Agent) -> Iterable[SetTitle]: - yield SetTitle(creator=creator, title=f"(╯°□°)╯︵ ┻━┻ {event.title}") - - -.. note: - Callbacks should **only** be used for actions that are specific to the - domain/concerns of the service in which they are implemented. For processes - that apply to all submissions, including asynchronous processes, - see :mod:`agent`. - - -Finally, :mod:`.services.classic` provides integration with the classic -submission database. We use the classic database to store events (new table), -and also keep its legacy tables up to date so that other legacy components -continue to work as expected. - - -Using commands/events -===================== - -Command/event classes are defined in :mod:`arxiv.submission.domain.event`, and -are accessible from the root namespace of this package. Each event type defines -a transformation/operation on a single submission, and defines the data -required to perform that operation. Events are played forward, in order, to -derive the state of a submission. For more information about how event types -are defined, see :class:`arxiv.submission.domain.event.Event`. - -.. note:: - - One major difference between the event stream and the classic submission - database table is that in the former model, there is only one submission id - for all versions/mutations. In the legacy system, new rows are created in - the submission table for things like creating a replacement, adding a DOI, - or requesting a withdrawal. The :ref:`legacy-integration` handles the - interchange between these two models. - -Commands/events types are `PEP 557 data classes -`_. Each command/event inherits from -:class:`.Event`, and may add additional fields. See :class:`.Event` for more -information about common fields. - -To create a new command/event, initialize the class with the relevant -data, and commit it using :func:`.save`. For example: - -.. code-block:: python - - >>> from arxiv.submission import User, SetTitle, save - >>> user = User(123, "joe@bloggs.com") - >>> update = SetTitle(creator=user, title='A new theory of foo') - >>> submission = save(creation, submission_id=12345) - - -If the commands/events are for a submission that already exists, the latest -state of that submission will be obtained by playing forward past events. New -events will be validated and applied to the submission in the order that they -were passed to :func:`.save`. - -- If an event is invalid (e.g. the submission is not in an appropriate state - for the operation), an :class:`.InvalidEvent` exception will be raised. - Note that at this point nothing has been changed in the database; the - attempt is simply abandoned. -- The command/event is stored, as is the latest state of the - submission. Events and the resulting state of the submission are stored - atomically. -- If the notification service is configured, a message about the event is - propagated as a Kinesis event on the configured stream. See - :mod:`arxiv.submission.services.notification` for details. - -Special case: creation ----------------------- -Note that if the first event is a :class:`.CreateSubmission` the -submission ID need not be provided, as we won't know what it is yet. For -example: - -.. code-block:: python - - from arxiv.submission import User, CreateSubmission, SetTitle, save - - >>> user = User(123, "joe@bloggs.com") - >>> creation = CreateSubmission(creator=user) - >>> update = SetTitle(creator=user, title='A new theory of foo') - >>> submission, events = save(creation, update) - >>> submission.submission_id - 40032 - - -.. _versioning-overview: - -Versioning events -================= -Handling changes to this software in a way that does not break past data is a -non-trivial problem. In a traditional relational database arrangement we would -leverage a database migration tool to do things like apply ``ALTER`` statements -to tables when upgrading software versions. The premise of the event data -model, however, is that events are immutable -- we won't be going back to -modify past events whenever we make a change to the software. - -The strategy for version management around event data is implemented in -:mod:`arxiv.submission.domain.events.versioning`. When event data is stored, -it is tagged with the current version of this software. When -event data are loaded from the store in this software, prior to instantiating -the appropriate :class:`.Event` subclass, the data are mapped to the current -software version using any defined version mappings for that event type. -This happens on the fly, in :func:`.domain.event.event_factory`. - - -.. _legacy-integration: - -Integration with the legacy system -================================== -The :mod:`.classic` service module provides integration with the classic -database. See the documentation for that module for details. As we migrate -off of the classic database, we will swap in a new service module with the -same API. - -Until all legacy components that read from or write to the classic database are -replaced, we will not be able to move entirely away from the legacy submission -database. Particularly in the submission and moderation UIs, design has assumed -immediate consistency, which means a conventional read/write interaction with -the database. Hence the classic integration module assumes that we are reading -and writing events and submission state from/to the same database. - -As development proceeds, we will look for opportunities to decouple from the -classic database, and focus on more localized projections of submission events -that are specific to a service/application. For example, the moderation UI/API -need not maintain or have access to the complete representation of the -submission; instead, it may track the subset of events relevant to its -operation (e.g. pertaining to metadata, classification, proposals, holds, etc). - -""" -import os -import time -from typing import Any, Protocol - -from flask import Flask, Blueprint - -import logging - -from .core import save, load, load_fast, SaveError -from .domain.agent import Agent, User, System, Client -from .domain.event import Event, InvalidEvent -from .domain.submission import Submission, SubmissionMetadata, Author -from .services import classic, StreamPublisher, Compiler, PlainTextService,\ - Classifier, PreviewService - -logger = logging.getLogger(__name__) - - -def init_app(app: Flask) -> None: - """ - Configure a Flask app to use this package. - - Initializes and waits for :class:`.StreamPublisher` and :mod:`.classic` - to be available. - """ - # Initialize services. - #logger.debug('Initialize StreamPublisher: %s', app.config['KINESIS_ENDPOINT']) - StreamPublisher.init_app(app) - #logger.debug('Initialize Preview Service: %s', app.config['PREVIEW_ENDPOINT']) - # PreviewService.init_app(app) - #logger.debug('Initialize Classic Database: %s', app.config['CLASSIC_DATABASE_URI']) - classic.init_app(app) - logger.debug('Done initializing classic DB') - - template_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), - 'templates') - app.register_blueprint( - Blueprint('submission-core', __name__, template_folder=template_folder) - ) - - logger.debug('Core: Wait for initializing services to spin up') - if app.config['WAIT_FOR_SERVICES']: - time.sleep(app.config['WAIT_ON_STARTUP']) - with app.app_context(): - stream_publisher = StreamPublisher.current_session() - stream_publisher.initialize() - wait_for(stream_publisher) - # Protocol doesn't work for modules. Either need a union type for - # wait_for, or use something other than a protocol for IAwaitable... - wait_for(classic) # type: ignore - logger.info('All upstream services are available; ready to start') - - -class IAwaitable(Protocol): - """An object that provides an ``is_available`` predicate.""" - - def is_available(self, **kwargs: Any) -> bool: - """Check whether an object (e.g. a service) is available.""" - ... - - -def wait_for(service: IAwaitable, delay: int = 2, **extra: Any) -> None: - """Wait for a service to become available.""" - if hasattr(service, '__name__'): - service_name = service.__name__ # type: ignore - elif hasattr(service, '__class__'): - service_name = service.__class__.__name__ - else: - service_name = str(service) - - logger.info('await %s', service_name) - while not service.is_available(**extra): - logger.info('service %s is not available; try again', service_name) - time.sleep(delay) - logger.info('service %s is available!', service_name) diff --git a/src/arxiv/submission/auth.py b/src/arxiv/submission/auth.py deleted file mode 100644 index 82eecaa..0000000 --- a/src/arxiv/submission/auth.py +++ /dev/null @@ -1,46 +0,0 @@ - -from typing import List -import uuid -from datetime import datetime, timedelta - -from arxiv_auth import domain -from arxiv_auth.auth import tokens, scopes -from pytz import UTC - -from arxiv.base.globals import get_application_config - -from src.arxiv.submission import Agent, User - - -def get_system_token(name: str, agent: Agent, scopes: List[str]) -> str: - start = datetime.now(tz=UTC) - end = start + timedelta(seconds=36000) - if isinstance(agent, User): - user = domain.User( - username=agent.username, - email=agent.email, - user_id=agent.identifier, - name=agent.name, - verified=True - ) - else: - user = None - session = domain.Session( - session_id=str(uuid.uuid4()), - start_time=datetime.now(), end_time=end, - user=user, - client=domain.Client( - owner_id='system', - client_id=name, - name=name - ), - authorizations=domain.Authorizations(scopes=scopes) - ) - secret = get_application_config()['JWT_SECRET'] - return str(tokens.encode(session, secret)) - - -def get_compiler_scopes(resource: str) -> List[str]: - """Get minimal auth scopes necessary for compilation integration.""" - return [scopes.READ_COMPILE.for_resource(resource), - scopes.CREATE_COMPILE.for_resource(resource)] diff --git a/src/arxiv/submission/config.py b/src/arxiv/submission/config.py deleted file mode 100644 index c195a0f..0000000 --- a/src/arxiv/submission/config.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Submission core configuration parameters.""" - -from os import environ -import warnings - -NAMESPACE = environ.get('NAMESPACE') -"""Namespace in which this service is deployed; to qualify keys for secrets.""" - -LOGLEVEL = int(environ.get('LOGLEVEL', '20')) -""" -Logging verbosity. - -See `https://docs.python.org/3/library/logging.html#levels`_. -""" - -JWT_SECRET = environ.get('JWT_SECRET') -"""Secret key for signing + verifying authentication JWTs.""" - -if not JWT_SECRET: - warnings.warn('JWT_SECRET is not set; authn/z may not work correctly!') - -CORE_VERSION = "0.0.0" - -MAX_SAVE_RETRIES = 25 -"""Number of times to retry storing/emiting a submission event.""" - -DEFAULT_SAVE_RETRY_DELAY = 30 -"""Delay between retry attempts when storing/emiting a submission event.""" - -WAIT_FOR_SERVICES = bool(int(environ.get('WAIT_FOR_SERVICES', '0'))) -"""Disable/enable waiting for upstream services to be available on startup.""" -if not WAIT_FOR_SERVICES: - warnings.warn('Awaiting upstream services is disabled; this should' - ' probably be enabled in production.') - -WAIT_ON_STARTUP = int(environ.get('WAIT_ON_STARTUP', '0')) -"""Number of seconds to wait before checking upstream services on startup.""" - -ENABLE_CALLBACKS = bool(int(environ.get('ENABLE_CALLBACKS', '1'))) -"""Enable/disable the :func:`Event.bind` feature.""" - - -# --- DATABASE CONFIGURATION --- - -CLASSIC_DATABASE_URI = environ.get('CLASSIC_DATABASE_URI', 'sqlite:///') -"""Full database URI for the classic system.""" - -SQLALCHEMY_DATABASE_URI = CLASSIC_DATABASE_URI -"""Full database URI for the classic system.""" - -SQLALCHEMY_TRACK_MODIFICATIONS = False -"""Track modifications feature should always be disabled.""" - -# --- AWS CONFIGURATION --- - -AWS_ACCESS_KEY_ID = environ.get('AWS_ACCESS_KEY_ID', 'nope') -""" -Access key for requests to AWS services. - -If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. -""" - -AWS_SECRET_ACCESS_KEY = environ.get('AWS_SECRET_ACCESS_KEY', 'nope') -""" -Secret auth key for requests to AWS services. - -If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. -""" - -AWS_REGION = environ.get('AWS_REGION', 'us-east-1') -"""Default region for calling AWS services.""" - - -# --- KINESIS CONFIGURATION --- - -KINESIS_STREAM = environ.get("KINESIS_STREAM", "SubmissionEvents") -"""Name of the stream on which to produce and consume events.""" - -KINESIS_SHARD_ID = environ.get("KINESIS_SHARD_ID", "0") -"""Shard ID for stream producer.""" - -KINESIS_ENDPOINT = environ.get("KINESIS_ENDPOINT", None) -""" -Alternate endpoint for connecting to Kinesis. - -If ``None``, uses the boto3 defaults for the :const:`AWS_REGION`. This is here -mainly to support development with localstack or other mocking frameworks. -""" - -KINESIS_VERIFY = bool(int(environ.get("KINESIS_VERIFY", "1"))) -""" -Enable/disable TLS certificate verification when connecting to Kinesis. - -This is here support development with localstack or other mocking frameworks. -""" - -if not KINESIS_VERIFY: - warnings.warn('Certificate verification for Kinesis is disabled; this' - ' should not be disabled in production.') - -# --- UPSTREAM SERVICE INTEGRATIONS --- -# -# See https://kubernetes.io/docs/concepts/services-networking/service/#environment-variables -# for details on service DNS and environment variables in k8s. - -# Integration with the file manager service. -FILEMANAGER_HOST = environ.get('FILEMANAGER_SERVICE_HOST', 'arxiv.org') -"""Hostname or addreess of the filemanager service.""" - -FILEMANAGER_PORT = environ.get('FILEMANAGER_SERVICE_PORT', '443') -"""Port for the filemanager service.""" - -FILEMANAGER_PROTO = environ.get(f'FILEMANAGER_PORT_{FILEMANAGER_PORT}_PROTO', - 'https') -"""Protocol for the filemanager service.""" - -FILEMANAGER_PATH = environ.get('FILEMANAGER_PATH', '').lstrip('/') -"""Path at which the filemanager service is deployed.""" - -FILEMANAGER_ENDPOINT = environ.get( - 'FILEMANAGER_ENDPOINT', - '%s://%s:%s/%s' % (FILEMANAGER_PROTO, FILEMANAGER_HOST, - FILEMANAGER_PORT, FILEMANAGER_PATH) -) -""" -Full URL to the root filemanager service API endpoint. - -If not explicitly provided, this is composed from :const:`FILEMANAGER_HOST`, -:const:`FILEMANAGER_PORT`, :const:`FILEMANAGER_PROTO`, and -:const:`FILEMANAGER_PATH`. -""" - -FILEMANAGER_VERIFY = bool(int(environ.get('FILEMANAGER_VERIFY', '1'))) -"""Enable/disable SSL certificate verification for filemanager service.""" - -if FILEMANAGER_PROTO == 'https' and not FILEMANAGER_VERIFY: - warnings.warn('Certificate verification for filemanager is disabled; this' - ' should not be disabled in production.') - -# Integration with the compiler service. -COMPILER_HOST = environ.get('COMPILER_SERVICE_HOST', 'arxiv.org') -"""Hostname or addreess of the compiler service.""" - -COMPILER_PORT = environ.get('COMPILER_SERVICE_PORT', '443') -"""Port for the compiler service.""" - -COMPILER_PROTO = environ.get(f'COMPILER_PORT_{COMPILER_PORT}_PROTO', 'https') -"""Protocol for the compiler service.""" - -COMPILER_PATH = environ.get('COMPILER_PATH', '') -"""Path at which the compiler service is deployed.""" - -COMPILER_ENDPOINT = environ.get( - 'COMPILER_ENDPOINT', - '%s://%s:%s/%s' % (COMPILER_PROTO, COMPILER_HOST, COMPILER_PORT, - COMPILER_PATH) -) -""" -Full URL to the root compiler service API endpoint. - -If not explicitly provided, this is composed from :const:`COMPILER_HOST`, -:const:`COMPILER_PORT`, :const:`COMPILER_PROTO`, and :const:`COMPILER_PATH`. -""" - -COMPILER_VERIFY = bool(int(environ.get('COMPILER_VERIFY', '1'))) -"""Enable/disable SSL certificate verification for compiler service.""" - -if COMPILER_PROTO == 'https' and not COMPILER_VERIFY: - warnings.warn('Certificate verification for compiler is disabled; this' - ' should not be disabled in production.') - -# Integration with the classifier service. -CLASSIFIER_HOST = environ.get('CLASSIFIER_SERVICE_HOST', 'localhost') -"""Hostname or addreess of the classifier service.""" - -CLASSIFIER_PORT = environ.get('CLASSIFIER_SERVICE_PORT', '8000') -"""Port for the classifier service.""" - -CLASSIFIER_PROTO = environ.get(f'CLASSIFIER_PORT_{CLASSIFIER_PORT}_PROTO', - 'http') -"""Protocol for the classifier service.""" - -CLASSIFIER_PATH = environ.get('CLASSIFIER_PATH', '/classifier/') -"""Path at which the classifier service is deployed.""" - -CLASSIFIER_ENDPOINT = environ.get( - 'CLASSIFIER_ENDPOINT', - '%s://%s:%s/%s' % (CLASSIFIER_PROTO, CLASSIFIER_HOST, CLASSIFIER_PORT, - CLASSIFIER_PATH) -) -""" -Full URL to the root classifier service API endpoint. - -If not explicitly provided, this is composed from :const:`CLASSIFIER_HOST`, -:const:`CLASSIFIER_PORT`, :const:`CLASSIFIER_PROTO`, and -:const:`CLASSIFIER_PATH`. -""" - -CLASSIFIER_VERIFY = bool(int(environ.get('CLASSIFIER_VERIFY', '0'))) -"""Enable/disable SSL certificate verification for classifier service.""" - -if CLASSIFIER_PROTO == 'https' and not CLASSIFIER_VERIFY: - warnings.warn('Certificate verification for classifier is disabled; this' - ' should not be disabled in production.') - -# Integration with plaintext extraction service. -PLAINTEXT_HOST = environ.get('PLAINTEXT_SERVICE_HOST', 'arxiv.org') -"""Hostname or addreess of the plaintext extraction service.""" - -PLAINTEXT_PORT = environ.get('PLAINTEXT_SERVICE_PORT', '443') -"""Port for the plaintext extraction service.""" - -PLAINTEXT_PROTO = environ.get(f'PLAINTEXT_PORT_{PLAINTEXT_PORT}_PROTO', - 'https') -"""Protocol for the plaintext extraction service.""" - -PLAINTEXT_PATH = environ.get('PLAINTEXT_PATH', '') -"""Path at which the plaintext extraction service is deployed.""" - -PLAINTEXT_ENDPOINT = environ.get( - 'PLAINTEXT_ENDPOINT', - '%s://%s:%s/%s' % (PLAINTEXT_PROTO, PLAINTEXT_HOST, PLAINTEXT_PORT, - PLAINTEXT_PATH) -) -""" -Full URL to the root plaintext extraction service API endpoint. - -If not explicitly provided, this is composed from :const:`PLAINTEXT_HOST`, -:const:`PLAINTEXT_PORT`, :const:`PLAINTEXT_PROTO`, and :const:`PLAINTEXT_PATH`. -""" - -PLAINTEXT_VERIFY = bool(int(environ.get('PLAINTEXT_VERIFY', '1'))) -"""Enable/disable certificate verification for plaintext extraction service.""" - -if PLAINTEXT_PROTO == 'https' and not PLAINTEXT_VERIFY: - warnings.warn('Certificate verification for plaintext extraction service' - ' is disabled; this should not be disabled in production.') - -# Email notification configuration. -EMAIL_ENABLED = bool(int(environ.get('EMAIL_ENABLED', '1'))) -"""Enable/disable sending e-mail. Default is enabled (True).""" - -DEFAULT_SENDER = environ.get('DEFAULT_SENDER', 'noreply@arxiv.org') -"""Default sender address for e-mail.""" - -SUPPORT_EMAIL = environ.get('SUPPORT_EMAIL', "help@arxiv.org") -"""E-mail address for user support.""" - -SMTP_HOSTNAME = environ.get('SMTP_HOSTNAME', 'localhost') -"""Hostname for the SMTP server.""" - -SMTP_USERNAME = environ.get('SMTP_USERNAME', 'foouser') -"""Username for the SMTP server.""" - -SMTP_PASSWORD = environ.get('SMTP_PASSWORD', 'foopass') -"""Password for the SMTP server.""" - -SMTP_PORT = int(environ.get('SMTP_PORT', '0')) -"""SMTP service port.""" - -SMTP_LOCAL_HOSTNAME = environ.get('SMTP_LOCAL_HOSTNAME', None) -"""Local host name to include in SMTP request.""" - -SMTP_SSL = bool(int(environ.get('SMTP_SSL', '0'))) -"""Enable/disable SSL for SMTP. Default is disabled.""" - -if not SMTP_SSL: - warnings.warn('Certificate verification for SMTP is disabled; this' - ' should not be disabled in production.') - - -# --- URL GENERATION --- - -EXTERNAL_URL_SCHEME = environ.get('EXTERNAL_URL_SCHEME', 'https') -"""Scheme to use for external URLs.""" - -if EXTERNAL_URL_SCHEME != 'https': - warnings.warn('External URLs will not use HTTPS proto') - -BASE_SERVER = environ.get('BASE_SERVER', 'arxiv.org') -"""Base arXiv server.""" - -SERVER_NAME = environ.get('SERVER_NAME', "submit.arxiv.org") -"""The name of this server.""" - -URLS = [ - ("submission", "/", SERVER_NAME), - ("confirmation", "//confirmation", SERVER_NAME) -] -""" -URLs for external services, for use with :func:`flask.url_for`. - -This subset of URLs is common only within submit, for now - maybe move to base -if these pages seem relevant to other services. - -For details, see :mod:`arxiv.base.urls`. -""" - -AUTH_UPDATED_SESSION_REF = True \ No newline at end of file diff --git a/src/arxiv/submission/core.py b/src/arxiv/submission/core.py deleted file mode 100644 index 2d09933..0000000 --- a/src/arxiv/submission/core.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Core persistence methods for submissions and submission events.""" - -from typing import Callable, List, Dict, Mapping, Tuple, Iterable, Optional -from functools import wraps -from collections import defaultdict -from datetime import datetime -from pytz import UTC - -from flask import Flask - -import logging - -from .domain.submission import Submission, SubmissionMetadata, Author -from .domain.agent import Agent, User, System, Client -from .domain.event import Event, CreateSubmission -from .services import classic, StreamPublisher -from .exceptions import InvalidEvent, NoSuchSubmission, SaveError, NothingToDo - - -logger = logging.getLogger(__name__) - - -def load(submission_id: int) -> Tuple[Submission, List[Event]]: - """ - Load a submission and its history. - - This loads all events for the submission, and generates the most - up-to-date representation based on those events. - - Parameters - ---------- - submission_id : str - Submission identifier. - - Returns - ------- - :class:`.domain.submission.Submission` - The current state of the submission. - list - Items are :class:`.Event` instances, in order of their occurrence. - - Raises - ------ - :class:`arxiv.submission.exceptions.NoSuchSubmission` - Raised when a submission with the passed ID cannot be found. - - """ - try: - with classic.transaction(): - return classic.get_submission(submission_id) - except classic.NoSuchSubmission as e: - raise NoSuchSubmission(f'No submission with id {submission_id}') from e - - -def load_submissions_for_user(user_id: int) -> List[Submission]: - """ - Load active :class:`.domain.submission.Submission` for a specific user. - - Parameters - ---------- - user_id : int - Unique identifier for the user. - - Returns - ------- - list - Items are :class:`.domain.submission.Submission` instances. - - """ - with classic.transaction(): - return classic.get_user_submissions_fast(user_id) - - -def load_fast(submission_id: int) -> Submission: - """ - Load a :class:`.domain.submission.Submission` from its projected state. - - This does not load and apply past events. The most recent stored submission - state is loaded directly from the database. - - Parameters - ---------- - submission_id : str - Submission identifier. - - Returns - ------- - :class:`.domain.submission.Submission` - The current state of the submission. - - """ - try: - with classic.transaction(): - return classic.get_submission_fast(submission_id) - except classic.NoSuchSubmission as e: - raise NoSuchSubmission(f'No submission with id {submission_id}') from e - - -def save(*events: Event, submission_id: Optional[int] = None) \ - -> Tuple[Submission, List[Event]]: - """ - Commit a set of new :class:`.Event` instances for a submission. - - This will persist the events to the database, along with the final - state of the submission, and generate external notification(s) on the - appropriate channels. - - Parameters - ---------- - events : :class:`.Event` - Events to apply and persist. - submission_id : int - The unique ID for the submission, if available. If not provided, it is - expected that ``events`` includes a :class:`.CreateSubmission`. - - Returns - ------- - :class:`arxiv.submission.domain.submission.Submission` - The state of the submission after all events (including rule-derived - events) have been applied. Updated with the submission ID, if a - :class:`.CreateSubmission` was included. - list - A list of :class:`.Event` instances applied to the submission. Note - that this list may contain more events than were passed, if event - rules were triggered. - - Raises - ------ - :class:`arxiv.submission.exceptions.NoSuchSubmission` - Raised if ``submission_id`` is not provided and the first event is not - a :class:`.CreateSubmission`, or ``submission_id`` is provided but - no such submission exists. - :class:`.InvalidEvent` - If an invalid event is encountered, the entire operation is aborted - and this exception is raised. - :class:`.SaveError` - There was a problem persisting the events and/or submission state - to the database. - - """ - if len(events) == 0: - raise NothingToDo('Must pass at least one event') - events_list = list(events) # Coerce to list so that we can index. - prior: List[Event] = [] - before: Optional[Submission] = None - - # We need ACIDity surrounding the the validation and persistence of new - # events. - with classic.transaction(): - # Get the current state of the submission from past events. Normally we - # would not want to load all past events, but legacy components may be - # active, and the legacy projected state does not capture all of the - # detail in the event model. - if submission_id is not None: - # This will create a shared lock on the submission rows while we - # are working with them. - before, prior = classic.get_submission(submission_id, - for_update=True) - - # Either we need a submission ID, or the first event must be a - # creation. - elif events_list[0].submission_id is None \ - and not isinstance(events_list[0], CreateSubmission): - raise NoSuchSubmission('Unable to determine submission') - - committed: List[Event] = [] - for event in events_list: - # Fill in submission IDs, if they are missing. - if event.submission_id is None and submission_id is not None: - event.submission_id = submission_id - - # The created timestamp should be roughly when the event was - # committed. Since the event projection may refer to its own ID - # (which is based) on the creation time, this must be set before - # the event is applied. - event.created = datetime.now(UTC) - # Mutation happens here; raises InvalidEvent. - logger.debug('Apply event %s: %s', event.event_id, event.NAME) - after = event.apply(before) - committed.append(event) - if not event.committed: - after, consequent_events = event.commit(_store_event) - committed += consequent_events - - before = after # Prepare for the next event. - - all_ = sorted(set(prior) | set(committed), key=lambda e: e.created) - return after, list(all_) - - -def _store_event(event: Event, before: Optional[Submission], - after: Submission) -> Tuple[Event, Submission]: - return classic.store_event(event, before, after, StreamPublisher.put) - - -def init_app(app: Flask) -> None: - """Set default configuration parameters for an application instance.""" - classic.init_app(app) - StreamPublisher.init_app(app) - app.config.setdefault('ENABLE_CALLBACKS', 0) - app.config.setdefault('ENABLE_ASYNC', 0) diff --git a/src/arxiv/submission/domain/__init__.py b/src/arxiv/submission/domain/__init__.py deleted file mode 100644 index 8ef74b5..0000000 --- a/src/arxiv/submission/domain/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Core data structures for the submission and moderation system.""" - -from .agent import User, System, Client, Agent, agent_factory -from .annotation import Comment -from .event import event_factory, Event -from .meta import Category, License, Classification -from .preview import Preview -from .proposal import Proposal -from .submission import Submission, SubmissionMetadata, Author, Hold, \ - WithdrawalRequest, UserRequest, CrossListClassificationRequest, \ - Compilation, SubmissionContent - diff --git a/src/arxiv/submission/domain/agent.py b/src/arxiv/submission/domain/agent.py deleted file mode 100644 index fd2494d..0000000 --- a/src/arxiv/submission/domain/agent.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Data structures for agents.""" - -import hashlib -from typing import Any, Optional, List, Union, Type, Dict - -from dataclasses import dataclass, field -from dataclasses import asdict - -from .meta import Classification - -__all__ = ('Agent', 'User', 'System', 'Client', 'agent_factory') - - -@dataclass -class Agent: - """ - Base class for agents in the submission system. - - An agent is an actor/system that generates/is responsible for events. - """ - - native_id: str - """Type-specific identifier for the agent. This might be an URI.""" - - hostname: Optional[str] = field(default=None) - """Hostname or IP address from which user requests are originating.""" - - name: str = field(default_factory=str) - username: str = field(default_factory=str) - email: str = field(default_factory=str) - endorsements: List[str] = field(default_factory=list) - - def __post_init__(self) -> None: - """Set derivative fields.""" - self.agent_type = self.__class__.get_agent_type() - self.agent_identifier = self.get_agent_identifier() - - @classmethod - def get_agent_type(cls) -> str: - """Get the name of the instance's class.""" - return cls.__name__ - - def get_agent_identifier(self) -> str: - """ - Get the unique identifier for this agent instance. - - Based on both the agent type and native ID. - """ - h = hashlib.new('sha1') - h.update(b'%s:%s' % (self.agent_type.encode('utf-8'), - str(self.native_id).encode('utf-8'))) - return h.hexdigest() - - def __eq__(self, other: Any) -> bool: - """Equality comparison for agents based on type and identifier.""" - if not isinstance(other, self.__class__): - return False - return self.agent_identifier == other.agent_identifier - - -@dataclass -class User(Agent): - """A human end user.""" - - forename: str = field(default_factory=str) - surname: str = field(default_factory=str) - suffix: str = field(default_factory=str) - identifier: Optional[str] = field(default=None) - affiliation: str = field(default_factory=str) - - agent_type: str = field(default_factory=str) - agent_identifier: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Set derivative fields.""" - self.name = self.get_name() - self.agent_type = self.get_agent_type() - - def get_name(self) -> str: - """Full name of the user.""" - return f"{self.forename} {self.surname} {self.suffix}" - - -# TODO: extend this to support arXiv-internal services. -@dataclass -class System(Agent): - """The submission application (this application).""" - - agent_type: str = field(default_factory=str) - agent_identifier: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Set derivative fields.""" - super(System, self).__post_init__() - self.username = self.native_id - self.name = self.native_id - self.hostname = self.native_id - self.agent_type = self.get_agent_type() - - -@dataclass -class Client(Agent): - """A non-human third party, usually an API client.""" - - # hostname: Optional[str] = field(default=None) - # """Hostname or IP address from which client requests are originating.""" - - agent_type: str = field(default_factory=str) - agent_identifier: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Set derivative fields.""" - self.agent_type = self.get_agent_type() - self.username = self.native_id - self.name = self.native_id - - -_agent_types: Dict[str, Type[Agent]] = { - User.get_agent_type(): User, - System.get_agent_type(): System, - Client.get_agent_type(): Client, -} - - -def agent_factory(**data: Union[Agent, dict]) -> Agent: - """Instantiate a subclass of :class:`.Agent`.""" - if isinstance(data, Agent): - return data - agent_type = str(data.pop('agent_type')) - native_id = data.pop('native_id') - if not agent_type or not native_id: - raise ValueError('No such agent: %s, %s' % (agent_type, native_id)) - if agent_type not in _agent_types: - raise ValueError(f'No such agent type: {agent_type}') - - # Mypy chokes on meta-stuff like this. One of the goals of this factory - # function is to not have to write code for each agent subclass. We can - # revisit this in the future. For now, this code is correct, it just isn't - # easy to type-check. - klass = _agent_types[agent_type] - data = {k: v for k, v in data.items() if k in klass.__dataclass_fields__} # type: ignore - return klass(native_id, **data) # type: ignore diff --git a/src/arxiv/submission/domain/annotation.py b/src/arxiv/submission/domain/annotation.py deleted file mode 100644 index 369df99..0000000 --- a/src/arxiv/submission/domain/annotation.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Provides quality-assurance annotations for the submission & moderation system. -""" - -import hashlib -from datetime import datetime -from enum import Enum -from typing import Optional, Union, List, Dict, Type, Any - -from dataclasses import dataclass, asdict, field -from mypy_extensions import TypedDict - -from arxiv.taxonomy import Category - -from .agent import Agent, agent_factory -from .util import get_tzaware_utc_now - - -@dataclass -class Comment: - """A freeform textual annotation.""" - - event_id: str - creator: Agent - created: datetime - proxy: Optional[Agent] = field(default=None) - body: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Check our agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - - -ClassifierResult = TypedDict('ClassifierResult', - {'category': Category, 'probability': float}) - - -@dataclass -class Annotation: - event_id: str - creator: Agent - created: datetime - - def __post_init__(self) -> None: - """Check our agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - - -@dataclass -class ClassifierResults(Annotation): - """Represents suggested classifications from an auto-classifier.""" - - class Classifiers(Enum): - """Supported classifiers.""" - - CLASSIC = "classic" - - # event_id: str - # creator: Agent - # created: datetime - proxy: Optional[Agent] = field(default=None) - classifier: Classifiers = field(default=Classifiers.CLASSIC) - results: List[ClassifierResult] = field(default_factory=list) - annotation_type: str = field(default='ClassifierResults') - - def __post_init__(self) -> None: - """Check our enums.""" - super(ClassifierResults, self).__post_init__() - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - self.classifier = self.Classifiers(self.classifier) - - -@dataclass -class Feature(Annotation): - """Represents features drawn from the content of the submission.""" - - class Type(Enum): - """Supported features.""" - - CHARACTER_COUNT = "chars" - PAGE_COUNT = "pages" - STOPWORD_COUNT = "stops" - STOPWORD_PERCENT = "%stop" - WORD_COUNT = "words" - - # event_id: str - # created: datetime - # creator: Agent - feature_type: Type - proxy: Optional[Agent] = field(default=None) - feature_value: Union[int, float] = field(default=0) - annotation_type: str = field(default='Feature') - - def __post_init__(self) -> None: - """Check our enums.""" - super(Feature, self).__post_init__() - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - self.feature_type = self.Type(self.feature_type) - - -annotation_types: Dict[str, Type[Annotation]] = { - 'Feature': Feature, - 'ClassifierResults': ClassifierResults -} - - -def annotation_factory(**data: Any) -> Annotation: - an: Annotation = annotation_types[data.pop('annotation_type')](**data) - return an diff --git a/src/arxiv/submission/domain/compilation.py b/src/arxiv/submission/domain/compilation.py deleted file mode 100644 index 2954ea0..0000000 --- a/src/arxiv/submission/domain/compilation.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Data structs related to compilation.""" - -import io -from datetime import datetime -from enum import Enum -from typing import Optional, NamedTuple, Dict - -from dataclasses import dataclass, field - - - -@dataclass -class Compilation: - """The state of a compilation attempt from the :mod:`.compiler` service.""" - - class Status(Enum): # type: ignore - """Acceptable compilation process statuses.""" - - SUCCEEDED = "completed" - IN_PROGRESS = "in_progress" - FAILED = "failed" - - class Format(Enum): # type: ignore - """Supported compilation output formats.""" - - PDF = "pdf" - DVI = "dvi" - PS = "ps" - - @property - def content_type(self) -> str: - """Get the MIME type for the compilation product.""" - _ctypes = { - type(self).PDF: 'application/pdf', - type(self).DVI: 'application/x-dvi', - type(self).PS: 'application/postscript' - } - return _ctypes[self] - - class SupportedCompiler(Enum): - """Compiler known to be supported by the compiler service.""" - - PDFLATEX = 'pdflatex' - - class Reason(Enum): - """Specific reasons for a (usually failure) outcome.""" - - AUTHORIZATION = "auth_error" - MISSING = "missing_source" - SOURCE_TYPE = "invalid_source_type" - CORRUPTED = "corrupted_source" - CANCELLED = "cancelled" - ERROR = "compilation_errors" - NETWORK = "network_error" - STORAGE = "storage" - DOCKER = 'docker' - NONE = None - - # Here are the actual slots/fields. - source_id: str - """This is the upload workspace identifier.""" - status: Status - """The status of the compilation.""" - checksum: str - """Checksum of the source package that we are compiling.""" - output_format: Format = field(default=Format.PDF) - """The requested output format.""" - reason: Reason = field(default=Reason.NONE) - """The specific reason for the :attr:`.status`.""" - description: Optional[str] = field(default=None) - """Additional detail about the :attr:`.status`.""" - size_bytes: int = field(default=0) - """The size of the compilation product in bytes.""" - product_checksum: Optional[str] = field(default=None) - """The checksum of the compilation product.""" - start_time: Optional[datetime] = field(default=None) - end_time: Optional[datetime] = field(default=None) - - def __post_init__(self) -> None: - """Check enums.""" - self.output_format = self.Format(self.output_format) - self.reason = self.Reason(self.reason) - if self.is_failed and self.is_succeeded: - raise ValueError('Cannot be failed, succeeded simultaneously') - if self.is_in_progress and self.is_finished: - raise ValueError('Cannot be finished, in progress simultaneously') - - @property - def identifier(self) -> str: - """Get the task identifier.""" - return self.get_identifier(self.source_id, self.checksum, - self.output_format) - - @staticmethod - def get_identifier(source_id: str, checksum: str, - output_format: Format = Format.PDF) -> str: - return f"{source_id}/{checksum}/{output_format.value}" - - @property - def content_type(self) -> str: - """Get the MIME type for the compilation product.""" - return str(self.output_format.content_type) - - @property - def is_succeeded(self) -> bool: - """Indicate whether or not the compilation ended successfully.""" - return bool(self.status == self.Status.SUCCEEDED) - - @property - def is_failed(self) -> bool: - """Indicate whether or not the compilation ended in failure.""" - return bool(self.status == self.Status.FAILED) - - @property - def is_finished(self) -> bool: - """Indicate whether or not the compilation ended.""" - return bool(self.is_succeeded or self.is_failed) - - @property - def is_in_progress(self) -> bool: - """Indicate whether or not the compilation is in progress.""" - return bool(not self.is_finished) - - -@dataclass -class CompilationProduct: - """Content of a compilation product itself.""" - - stream: io.BytesIO - """Readable buffer with the product content.""" - - content_type: str - """MIME-type of the stream.""" - - status: Optional[Compilation] = field(default=None) - """Status information about the product.""" - - checksum: Optional[str] = field(default=None) - """The B64-encoded MD5 hash of the compilation product.""" - - def __post_init__(self) -> None: - """Check status.""" - if self.status and isinstance(self.status, dict): - self.status = Compilation(**self.status) - - -@dataclass -class CompilationLog: - """Content of a compilation log.""" - - stream: io.BytesIO - """Readable buffer with the product content.""" - - status: Optional[Compilation] = field(default=None) - """Status information about the log.""" - - checksum: Optional[str] = field(default=None) - """The B64-encoded MD5 hash of the log.""" - - content_type: str = field(default='text/plain') - """MIME-type of the stream.""" - - def __post_init__(self) -> None: - """Check status.""" - if self.status and isinstance(self.status, dict): - self.status = Compilation(**self.status) diff --git a/src/arxiv/submission/domain/event/__init__.py b/src/arxiv/submission/domain/event/__init__.py deleted file mode 100644 index 94c5c8f..0000000 --- a/src/arxiv/submission/domain/event/__init__.py +++ /dev/null @@ -1,1354 +0,0 @@ -""" -Data structures for submissions events. - -- Events have unique identifiers generated from their data (creation, agent, - submission). -- Events provide methods to update a submission based on the event data. -- Events provide validation methods for event data. - -Writing new events/commands -=========================== - -Events/commands are implemented as classes that inherit from :class:`.Event`. -It should: - -- Be a dataclass (i.e. be decorated with :func:`dataclasses.dataclass`). -- Define (using :func:`dataclasses.field`) associated data. -- Implement a validation method with the signature - ``validate(self, submission: Submission) -> None`` (see below). -- Implement a projection method with the signature - ``project(self, submission: Submission) -> Submission:`` that mutates - the passed :class:`.domain.submission.Submission` instance. - The projection *must not* generate side-effects, because it will be called - any time we are generating the state of a submission. If you need to - generate a side-effect, see :ref:`callbacks`\. -- Be fully documented. Be sure that the class docstring fully describes the - meaning of the event/command, and that both public and private methods have - at least a summary docstring. -- Have a corresponding :class:`unittest.TestCase` in - :mod:`arxiv.submission.domain.tests.test_events`. - -Adding validation to events -=========================== - -Each command/event class should implement an instance method -``validate(self, submission: Submission) -> None`` that raises -:class:`.InvalidEvent` exceptions if the data on the event instance is not -valid. - -For clarity, it's a good practice to individuate validation steps as separate -private instance methods, and call them from the public ``validate`` method. -This makes it easier to identify which validation criteria are being applied, -in what order, and what those criteria mean. - -See :class:`.SetPrimaryClassification` for an example. - -We could consider standalone validation functions for validation checks that -are performed on several event types (instead of just private instance -methods). - -.. _callbacks: - -Registering event callbacks -=========================== - -The base :class:`Event` provides support for callbacks that are executed when -an event instance is committed. To attach a callback to an event type, use the -:func:`Event.bind` decorator. For example: - -.. code-block:: python - - @SetTitle.bind() - def do_this_when_a_title_is_set(event, before, after, agent): - ... - return [] - - -Callbacks must have the signature ``(event: Event, before: Submission, -after: Submission, creator: Agent) -> Iterable[Event]``. ``event`` is the -event instance being committed that triggered the callback. ``before`` and -``after`` are the states of the submission before and after the event was -applied, respectively. ``agent`` is the agent responsible for any subsequent -events created by the callback, and should be used for that purpose. - -The callback should not concern itself with persistence; that is handled by -:func:`Event.commit`. Any mutations of submission should be made by returning -the appropriate command/event instances. - -The circumstances under which the callback is executed can be controlled by -passing a condition callable to the decorator. This should have the signature -``(event: Event, before: Submission, after: Submission, creator: Agent) -> -bool``; if it returns ``True``, the callback will be executed. For example: - -.. code-block:: python - - @SetTitle.bind(condition=lambda e, b, a, c: e.title == 'foo') - def do_this_when_a_title_is_set_to_foo(event, before, after, agent): - ... - return [] - - -When do things actually happen? -------------------------------- -Callbacks are triggered when the :func:`.commit` method is called, -usually by :func:`.core.save`. Normally, any event instances returned -by the callback are applied and committed right away, in order. - -Setting :mod:`.config.ENABLE_CALLBACKS=0` will disable callbacks -entirely. - -""" - -import copy -import hashlib -import re -from collections import defaultdict -from datetime import datetime -from functools import wraps -from typing import Optional, TypeVar, List, Tuple, Any, Dict, Union, Iterable,\ - Callable, ClassVar, Mapping -from urllib.parse import urlparse - -import bleach -from dataclasses import field, asdict -from pytz import UTC - -from arxiv import taxonomy -#from arxiv import identifier as arxiv_identifier -from arxiv.base import logging -from arxiv.base.globals import get_application_config - -from ...exceptions import InvalidEvent - -from ..agent import Agent, System, agent_factory -from ..annotation import Comment, Feature, ClassifierResults, \ - ClassifierResult -from ..preview import Preview -from ..submission import Submission, SubmissionMetadata, Author, \ - Classification, License, Delegation, \ - SubmissionContent, WithdrawalRequest, CrossListClassificationRequest -from ..util import get_tzaware_utc_now - -from . import validators -from .base import Event, event_factory, EventType -from .flag import AddMetadataFlag, AddUserFlag, AddContentFlag, RemoveFlag, \ - AddHold, RemoveHold -from .process import AddProcessStatus -from .proposal import AddProposal, RejectProposal, AcceptProposal -from .request import RequestCrossList, RequestWithdrawal, ApplyRequest, \ - RejectRequest, ApproveRequest, CancelRequest -from .util import dataclass - -logger = logging.getLogger(__name__) - - -# Events related to the creation of a new submission. -# -# These are largely the domain of the metadata API, and the submission UI. - - -@dataclass() -class CreateSubmission(Event): - """Creation of a new :class:`.domain.submission.Submission`.""" - - NAME = "create submission" - NAMED = "submission created" - - # This is the one event that deviates from the base Event class in not - # requiring/accepting a submission on which to operate. Python/mypy still - # has a little way to go in terms of supporting this kind of inheritance - # scenario. For reference, see: - # - https://github.com/python/typing/issues/269 - # - https://github.com/python/mypy/issues/5146 - # - https://github.com/python/typing/issues/241 - def validate(self, submission: None = None) -> None: # type: ignore - """Validate creation of a submission.""" - return - - def project(self, submission: None = None) -> Submission: # type: ignore - """Create a new :class:`.domain.submission.Submission`.""" - return Submission(creator=self.creator, created=self.created, - owner=self.creator, proxy=self.proxy, - client=self.client) - - -@dataclass(init=False) -class CreateSubmissionVersion(Event): - """ - Creates a new version of a submission. - - Takes the submission back to "working" state; the user or client may make - additional changes before finalizing the submission. - """ - - NAME = "create a new version" - NAMED = "new version created" - - def validate(self, submission: Submission) -> None: - """Only applies to announced submissions.""" - if not submission.is_announced: - raise InvalidEvent(self, "Must already be announced") - validators.no_active_requests(self, submission) - - def project(self, submission: Submission) -> Submission: - """Increment the version number, and reset several fields.""" - submission.version += 1 - submission.status = Submission.WORKING - # Return these to default. - submission.status = Submission.status - submission.source_content = Submission.source_content - submission.license = Submission.license - submission.submitter_is_author = Submission.submitter_is_author - submission.submitter_contact_verified = \ - Submission.submitter_contact_verified - submission.submitter_accepts_policy = \ - Submission.submitter_accepts_policy - submission.submitter_confirmed_preview = \ - Submission.submitter_confirmed_preview - return submission - - -@dataclass(init=False) -class Rollback(Event): - """Roll back to the most recent announced version, or delete.""" - - NAME = "roll back or delete" - NAMED = "rolled back or deleted" - - def validate(self, submission: Submission) -> None: - """Only applies to submissions in an unannounced state.""" - if submission.is_announced: - raise InvalidEvent(self, "Cannot already be announced") - elif submission.version > 1 and not submission.versions: - raise InvalidEvent(self, "No announced version to which to revert") - - def project(self, submission: Submission) -> Submission: - """Decrement the version number, and reset fields.""" - if submission.version == 1: - submission.status = Submission.DELETED - return submission - submission.version -= 1 - target = submission.versions[-1] - # Return these to last announced state. - submission.status = target.status - submission.source_content = target.source_content - submission.submitter_contact_verified = \ - target.submitter_contact_verified - submission.submitter_accepts_policy = \ - target.submitter_accepts_policy - submission.submitter_confirmed_preview = \ - target.submitter_confirmed_preview - submission.license = target.license - submission.metadata = copy.deepcopy(target.metadata) - return submission - - -@dataclass(init=False) -class ConfirmContactInformation(Event): - """Submitter has verified their contact information.""" - - NAME = "confirm contact information" - NAMED = "contact information confirmed" - - def validate(self, submission: Submission) -> None: - """Cannot apply to a finalized submission.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Update :attr:`.Submission.submitter_contact_verified`.""" - submission.submitter_contact_verified = True - return submission - - -@dataclass() -class ConfirmAuthorship(Event): - """The submitting user asserts whether they are an author of the paper.""" - - NAME = "confirm that submitter is an author" - NAMED = "submitter authorship status confirmed" - - submitter_is_author: bool = True - - def validate(self, submission: Submission) -> None: - """Cannot apply to a finalized submission.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Update the authorship flag on the submission.""" - submission.submitter_is_author = self.submitter_is_author - return submission - - -@dataclass(init=False) -class ConfirmPolicy(Event): - """The submitting user accepts the arXiv submission policy.""" - - NAME = "confirm policy acceptance" - NAMED = "policy acceptance confirmed" - - def validate(self, submission: Submission) -> None: - """Cannot apply to a finalized submission.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Set the policy flag on the submission.""" - submission.submitter_accepts_policy = True - return submission - - -@dataclass() -class SetPrimaryClassification(Event): - """Update the primary classification of a submission.""" - - NAME = "set primary classification" - NAMED = "primary classification set" - - category: Optional[taxonomy.Category] = None - - def validate(self, submission: Submission) -> None: - """Validate the primary classification category.""" - assert self.category is not None - validators.must_be_an_active_category(self, self.category, submission) - self._creator_must_be_endorsed(submission) - self._must_be_unannounced(submission) - validators.submission_is_not_finalized(self, submission) - validators.cannot_be_secondary(self, self.category, submission) - - def _must_be_unannounced(self, submission: Submission) -> None: - """Can only be set on the first version before publication.""" - if submission.arxiv_id is not None or submission.version > 1: - raise InvalidEvent(self, "Can only be set on the first version," - " before publication.") - - def _creator_must_be_endorsed(self, submission: Submission) -> None: - """Creator of this event must be endorsed for the category.""" - if isinstance(self.creator, System): - return - try: - archive = taxonomy.CATEGORIES[self.category]['in_archive'] - except KeyError: - archive = self.category - if self.category not in self.creator.endorsements \ - and f'{archive}.*' not in self.creator.endorsements \ - and '*.*' not in self.creator.endorsements: - raise InvalidEvent(self, f"Creator is not endorsed for" - f" {self.category}.") - - def project(self, submission: Submission) -> Submission: - """Set :attr:`.domain.Submission.primary_classification`.""" - assert self.category is not None - clsn = Classification(category=self.category) - submission.primary_classification = clsn - return submission - - def __post_init__(self) -> None: - """Ensure that we have an :class:`arxiv.taxonomy.Category`.""" - super(SetPrimaryClassification, self).__post_init__() - if self.category and not isinstance(self.category, taxonomy.Category): - self.category = taxonomy.Category(self.category) - - -@dataclass() -class AddSecondaryClassification(Event): - """Add a secondary :class:`.Classification` to a submission.""" - - NAME = "add cross-list classification" - NAMED = "cross-list classification added" - - category: Optional[taxonomy.Category] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Validate the secondary classification category to add.""" - assert self.category is not None - validators.must_be_an_active_category(self, self.category, submission) - validators.cannot_be_primary(self, self.category, submission) - validators.cannot_be_secondary(self, self.category, submission) - validators.max_secondaries(self, submission) - validators.no_redundant_general_category(self, self.category, submission) - validators.no_redundant_non_general_category(self, self.category, submission) - validators.cannot_be_genph(self, self.category, submission) - - def project(self, submission: Submission) -> Submission: - """Add a :class:`.Classification` as a secondary classification.""" - assert self.category is not None - classification = Classification(category=self.category) - submission.secondary_classification.append(classification) - return submission - - def __post_init__(self) -> None: - """Ensure that we have an :class:`arxiv.taxonomy.Category`.""" - super(AddSecondaryClassification, self).__post_init__() - if self.category and not isinstance(self.category, taxonomy.Category): - self.category = taxonomy.Category(self.category) - - -@dataclass() -class RemoveSecondaryClassification(Event): - """Remove secondary :class:`.Classification` from submission.""" - - NAME = "remove cross-list classification" - NAMED = "cross-list classification removed" - - category: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Validate the secondary classification category to remove.""" - assert self.category is not None - validators.must_be_an_active_category(self, self.category, submission) - self._must_already_be_present(submission) - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Remove from :attr:`.Submission.secondary_classification`.""" - assert self.category is not None - submission.secondary_classification = [ - classn for classn in submission.secondary_classification - if not classn.category == self.category - ] - return submission - - def _must_already_be_present(self, submission: Submission) -> None: - """One cannot remove a secondary that is not actually set.""" - if self.category not in submission.secondary_categories: - raise InvalidEvent(self, 'No such category on submission') - - -@dataclass() -class SetLicense(Event): - """The submitter has selected a license for their submission.""" - - NAME = "select distribution license" - NAMED = "distribution license selected" - - license_name: Optional[str] = field(default=None) - license_uri: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Validate the selected license.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Set :attr:`.domain.Submission.license`.""" - assert self.license_uri is not None - submission.license = License(name=self.license_name, - uri=self.license_uri) - return submission - - -@dataclass() -class SetTitle(Event): - """Update the title of a submission.""" - - NAME = "update title" - NAMED = "title updated" - - title: str = field(default='') - - MIN_LENGTH = 5 - MAX_LENGTH = 240 - ALLOWED_HTML = ["br", "sup", "sub", "hr", "em", "strong", "h"] - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetTitle, self).__post_init__() - self.title = self.cleanup(self.title) - - def validate(self, submission: Submission) -> None: - """Validate the title value.""" - validators.submission_is_not_finalized(self, submission) - self._does_not_contain_html_escapes(submission) - self._acceptable_length(submission) - validators.no_trailing_period(self, submission, self.title) - if self.title.isupper(): - raise InvalidEvent(self, "Title must not be all-caps") - self._check_for_html(submission) - - def project(self, submission: Submission) -> Submission: - """Update the title on a :class:`.domain.submission.Submission`.""" - submission.metadata.title = self.title - return submission - - def _does_not_contain_html_escapes(self, submission: Submission) -> None: - """The title must not contain HTML escapes.""" - if re.search(r"\&(?:[a-z]{3,4}|#x?[0-9a-f]{1,4})\;", self.title): - raise InvalidEvent(self, "Title may not contain HTML escapes") - - def _acceptable_length(self, submission: Submission) -> None: - """Verify that the title is an acceptable length.""" - N = len(self.title) - if N < self.MIN_LENGTH or N > self.MAX_LENGTH: - raise InvalidEvent(self, f"Title must be between {self.MIN_LENGTH}" - f" and {self.MAX_LENGTH} characters") - - # In classic, this is only an admin post-hoc check. - def _check_for_html(self, submission: Submission) -> None: - """Check for disallowed HTML.""" - N = len(self.title) - N_after = len(bleach.clean(self.title, tags=self.ALLOWED_HTML, - strip=True)) - if N > N_after: - raise InvalidEvent(self, "Title contains unacceptable HTML tags") - - @staticmethod - def cleanup(value: str) -> str: - """Perform some light tidying on the title.""" - value = re.sub(r"\s+", " ", value).strip() # Single spaces only. - return value - - -@dataclass() -class SetAbstract(Event): - """Update the abstract of a submission.""" - - NAME = "update abstract" - NAMED = "abstract updated" - - abstract: str = field(default='') - - MIN_LENGTH = 20 - MAX_LENGTH = 1920 - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetAbstract, self).__post_init__() - self.abstract = self.cleanup(self.abstract) - - def validate(self, submission: Submission) -> None: - """Validate the abstract value.""" - validators.submission_is_not_finalized(self, submission) - self._acceptable_length(submission) - - def project(self, submission: Submission) -> Submission: - """Update the abstract on a :class:`.domain.submission.Submission`.""" - submission.metadata.abstract = self.abstract - return submission - - def _acceptable_length(self, submission: Submission) -> None: - N = len(self.abstract) - if N < self.MIN_LENGTH or N > self.MAX_LENGTH: - raise InvalidEvent(self, - f"Abstract must be between {self.MIN_LENGTH}" - f" and {self.MAX_LENGTH} characters") - - @staticmethod - def cleanup(value: str) -> str: - """Perform some light tidying on the abstract.""" - value = value.strip() # Remove leading or trailing spaces - # Tidy paragraphs which should be indicated with "\n ". - value = re.sub(r"[ ]+\n", "\n", value) - value = re.sub(r"\n\s+", "\n ", value) - # Newline with no following space is removed, so treated as just a - # space in paragraph. - value = re.sub(r"(\S)\n(\S)", "\g<1> \g<2>", value) - # Tab->space, multiple spaces->space. - value = re.sub(r"\t", " ", value) - value = re.sub(r"(?", value) - # Remove lone period. - value = re.sub(r"\n\.\n", "\n", value) - value = re.sub(r"\n\.$", "", value) - return value - - -@dataclass() -class SetDOI(Event): - """Update the external DOI of a submission.""" - - NAME = "add a DOI" - NAMED = "DOI added" - - doi: str = field(default='') - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetDOI, self).__post_init__() - self.doi = self.cleanup(self.doi) - - def validate(self, submission: Submission) -> None: - """Validate the DOI value.""" - if submission.status == Submission.SUBMITTED \ - and not submission.is_announced: - raise InvalidEvent(self, 'Cannot edit a finalized submission') - if not self.doi: # Can be blank. - return - for value in re.split('[;,]', self.doi): - if not self._valid_doi(value.strip()): - raise InvalidEvent(self, f"Invalid DOI: {value}") - - def project(self, submission: Submission) -> Submission: - """Update the doi on a :class:`.domain.submission.Submission`.""" - submission.metadata.doi = self.doi - return submission - - def _valid_doi(self, value: str) -> bool: - if re.match(r"^10\.\d{4,5}\/\S+$", value): - return True - return False - - @staticmethod - def cleanup(value: str) -> str: - """Perform some light tidying on the title.""" - value = re.sub(r"\s+", " ", value).strip() # Single spaces only. - return value - - -@dataclass() -class SetMSCClassification(Event): - """Update the MSC classification codes of a submission.""" - - NAME = "update MSC classification" - NAMED = "MSC classification updated" - - msc_class: str = field(default='') - - MAX_LENGTH = 160 - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetMSCClassification, self).__post_init__() - self.msc_class = self.cleanup(self.msc_class) - - def validate(self, submission: Submission) -> None: - """Validate the MSC classification value.""" - validators.submission_is_not_finalized(self, submission) - if not self.msc_class: # Blank values are OK. - return - - def project(self, submission: Submission) -> Submission: - """Update the MSC classification on a :class:`.domain.submission.Submission`.""" - submission.metadata.msc_class = self.msc_class - return submission - - @staticmethod - def cleanup(value: str) -> str: - """Perform some light fixes on the MSC classification value.""" - value = re.sub(r"\s+", " ", value).strip() - value = re.sub(r"\s*\.[\s.]*$", "", value) - value = value.replace(";", ",") # No semicolons, should be comma. - value = re.sub(r"\s*,\s*", ", ", value) # Want: comma, space. - value = re.sub(r"^MSC([\s:\-]{0,4}(classification|class|number))?" - r"([\s:\-]{0,4}\(?2000\)?)?[\s:\-]*", - "", value, flags=re.I) - return value - - -@dataclass() -class SetACMClassification(Event): - """Update the ACM classification codes of a submission.""" - - NAME = "update ACM classification" - NAMED = "ACM classification updated" - - acm_class: str = field(default='') - """E.g. F.2.2; I.2.7""" - - MAX_LENGTH = 160 - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetACMClassification, self).__post_init__() - self.acm_class = self.cleanup(self.acm_class) - - def validate(self, submission: Submission) -> None: - """Validate the ACM classification value.""" - validators.submission_is_not_finalized(self, submission) - if not self.acm_class: # Blank values are OK. - return - self._valid_acm_class(submission) - - def project(self, submission: Submission) -> Submission: - """Update the ACM classification on a :class:`.domain.submission.Submission`.""" - submission.metadata.acm_class = self.acm_class - return submission - - def _valid_acm_class(self, submission: Submission) -> None: - """Check that the value is a valid ACM class.""" - ptn = r"^[A-K]\.[0-9m](\.(\d{1,2}|m)(\.[a-o])?)?$" - for acm_class in self.acm_class.split(';'): - if not re.match(ptn, acm_class.strip()): - raise InvalidEvent(self, f"Not a valid ACM class: {acm_class}") - - @staticmethod - def cleanup(value: str) -> str: - """Perform light cleanup.""" - value = re.sub(r"\s+", " ", value).strip() - value = re.sub(r"\s*\.[\s.]*$", "", value) - value = re.sub(r"^ACM-class:\s+", "", value, flags=re.I) - value = value.replace(",", ";") - _value = [] - for v in value.split(';'): - v = v.strip().upper().rstrip('.') - v = re.sub(r"^([A-K])(\d)", "\g<1>.\g<2>", v) - v = re.sub(r"M$", "m", v) - _value.append(v) - value = "; ".join(_value) - return value - - -@dataclass() -class SetJournalReference(Event): - """Update the journal reference of a submission.""" - - NAME = "add a journal reference" - NAMED = "journal reference added" - - journal_ref: str = field(default='') - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetJournalReference, self).__post_init__() - self.journal_ref = self.cleanup(self.journal_ref) - - def validate(self, submission: Submission) -> None: - """Validate the journal reference value.""" - if not self.journal_ref: # Blank values are OK. - return - self._no_disallowed_words(submission) - self._contains_valid_year(submission) - - def project(self, submission: Submission) -> Submission: - """Update the journal reference on a :class:`.domain.submission.Submission`.""" - submission.metadata.journal_ref = self.journal_ref - return submission - - def _no_disallowed_words(self, submission: Submission) -> None: - """Certain words are not permitted.""" - for word in ['submit', 'in press', 'appear', 'accept', 'to be publ']: - if word in self.journal_ref.lower(): - raise InvalidEvent(self, - f"The word '{word}' should appear in the" - f" comments, not the Journal ref") - - def _contains_valid_year(self, submission: Submission) -> None: - """Must contain a valid year.""" - if not re.search(r"(\A|\D)(19|20)\d\d(\D|\Z)", self.journal_ref): - raise InvalidEvent(self, "Journal reference must include a year") - - @staticmethod - def cleanup(value: str) -> str: - """Perform light cleanup.""" - value = value.replace('PHYSICAL REVIEW LETTERS', - 'Physical Review Letters') - value = value.replace('PHYSICAL REVIEW', 'Physical Review') - value = value.replace('OPTICS LETTERS', 'Optics Letters') - return value - - -@dataclass() -class SetReportNumber(Event): - """Update the report number of a submission.""" - - NAME = "update report number" - NAMED = "report number updated" - - report_num: str = field(default='') - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetReportNumber, self).__post_init__() - self.report_num = self.cleanup(self.report_num) - - def validate(self, submission: Submission) -> None: - """Validate the report number value.""" - if not self.report_num: # Blank values are OK. - return - if not re.search(r"\d\d", self.report_num): - raise InvalidEvent(self, "Report number must contain two" - " consecutive digits") - - def project(self, submission: Submission) -> Submission: - """Set report number on a :class:`.domain.submission.Submission`.""" - submission.metadata.report_num = self.report_num - return submission - - @staticmethod - def cleanup(value: str) -> str: - """Light cleanup on report number value.""" - value = re.sub(r"\s+", " ", value).strip() - value = re.sub(r"\s*\.[\s.]*$", "", value) - return value - - -@dataclass() -class SetComments(Event): - """Update the comments of a submission.""" - - NAME = "update comments" - NAMED = "comments updated" - - comments: str = field(default='') - - MAX_LENGTH = 400 - - def __post_init__(self) -> None: - """Perform some light cleanup on the provided value.""" - super(SetComments, self).__post_init__() - self.comments = self.cleanup(self.comments) - - def validate(self, submission: Submission) -> None: - """Validate the comments value.""" - validators.submission_is_not_finalized(self, submission) - if not self.comments: # Blank values are OK. - return - if len(self.comments) > self.MAX_LENGTH: - raise InvalidEvent(self, f"Comments must be no more than" - f" {self.MAX_LENGTH} characters long") - - def project(self, submission: Submission) -> Submission: - """Update the comments on a :class:`.domain.submission.Submission`.""" - submission.metadata.comments = self.comments - return submission - - @staticmethod - def cleanup(value: str) -> str: - """Light cleanup on comment value.""" - value = re.sub(r"\s+", " ", value).strip() - value = re.sub(r"\s*\.[\s.]*$", "", value) - return value - - -@dataclass() -class SetAuthors(Event): - """Update the authors on a :class:`.domain.submission.Submission`.""" - - NAME = "update authors" - NAMED = "authors updated" - - authors: List[Author] = field(default_factory=list) - authors_display: Optional[str] = field(default=None) - """The authors string may be provided.""" - - def __post_init__(self) -> None: - """Autogenerate and/or clean display names.""" - super(SetAuthors, self).__post_init__() - self.authors = [ - Author(**a) if isinstance(a, dict) else a # type: ignore - for a in self.authors - ] - if not self.authors_display: - self.authors_display = self._canonical_author_string() - self.authors_display = self.cleanup(self.authors_display) - - def validate(self, submission: Submission) -> None: - """May not apply to a finalized submission.""" - validators.submission_is_not_finalized(self, submission) - self._does_not_contain_et_al() - - def _canonical_author_string(self) -> str: - """Canonical representation of authors, using display names.""" - return ", ".join([au.display for au in self.authors - if au.display is not None]) - - @staticmethod - def cleanup(s: str) -> str: - """Perform some light tidying on the provided author string(s).""" - s = re.sub(r"\s+", " ", s) # Single spaces only. - s = re.sub(r",(\s*,)+", ",", s) # Remove double commas. - # Add spaces between word and opening parenthesis. - s = re.sub(r"(\w)\(", r"\g<1> (", s) - # Add spaces between closing parenthesis and word. - s = re.sub(r"\)(\w)", r") \g<1>", s) - # Change capitalized or uppercase `And` to `and`. - s = re.sub(r"\bA(?i:ND)\b", "and", s) - return s.strip() # Removing leading and trailing whitespace. - - def _does_not_contain_et_al(self) -> None: - """The authors display value should not contain `et al`.""" - if self.authors_display and \ - re.search(r"et al\.?($|\s*\()", self.authors_display): - raise InvalidEvent(self, "Authors should not contain et al.") - - def project(self, submission: Submission) -> Submission: - """Replace :attr:`.Submission.metadata.authors`.""" - assert self.authors_display is not None - submission.metadata.authors = self.authors - submission.metadata.authors_display = self.authors_display - return submission - - -@dataclass() -class SetUploadPackage(Event): - """Set the upload workspace for this submission.""" - - NAME = "set the upload package" - NAMED = "upload package set" - - identifier: str = field(default_factory=str) - checksum: str = field(default_factory=str) - uncompressed_size: int = field(default=0) - compressed_size: int = field(default=0) - source_format: SubmissionContent.Format = \ - field(default=SubmissionContent.Format.UNKNOWN) - - def __post_init__(self) -> None: - """Make sure that `source_format` is an enum instance.""" - super(SetUploadPackage, self).__post_init__() - if type(self.source_format) is str: - self.source_format = SubmissionContent.Format(self.source_format) - - def validate(self, submission: Submission) -> None: - """Validate data for :class:`.SetUploadPackage`.""" - validators.submission_is_not_finalized(self, submission) - - if not self.identifier: - raise InvalidEvent(self, 'Missing upload ID') - - def project(self, submission: Submission) -> Submission: - """Replace :class:`.SubmissionContent` metadata on the submission.""" - submission.source_content = SubmissionContent( - checksum=self.checksum, - identifier=self.identifier, - uncompressed_size=self.uncompressed_size, - compressed_size=self.compressed_size, - source_format=self.source_format, - ) - submission.submitter_confirmed_preview = False - return submission - - -@dataclass() -class UpdateUploadPackage(Event): - """Update the upload workspace on this submission.""" - - NAME = "update the upload package" - NAMED = "upload package updated" - - checksum: str = field(default_factory=str) - uncompressed_size: int = field(default=0) - compressed_size: int = field(default=0) - source_format: SubmissionContent.Format = \ - field(default=SubmissionContent.Format.UNKNOWN) - - def __post_init__(self) -> None: - """Make sure that `source_format` is an enum instance.""" - super(UpdateUploadPackage, self).__post_init__() - if type(self.source_format) is str: - self.source_format = SubmissionContent.Format(self.source_format) - - def validate(self, submission: Submission) -> None: - """Validate data for :class:`.SetUploadPackage`.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Replace :class:`.SubmissionContent` metadata on the submission.""" - assert submission.source_content is not None - assert self.source_format is not None - assert self.checksum is not None - assert self.uncompressed_size is not None - assert self.compressed_size is not None - submission.source_content.source_format = self.source_format - submission.source_content.checksum = self.checksum - submission.source_content.uncompressed_size = self.uncompressed_size - submission.source_content.compressed_size = self.compressed_size - submission.submitter_confirmed_preview = False - return submission - - -@dataclass() -class UnsetUploadPackage(Event): - """Unset the upload workspace for this submission.""" - - NAME = "unset the upload package" - NAMED = "upload package unset" - - def validate(self, submission: Submission) -> None: - """Validate data for :class:`.UnsetUploadPackage`.""" - validators.submission_is_not_finalized(self, submission) - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.source_content` to None.""" - submission.source_content = None - submission.submitter_confirmed_preview = False - return submission - - -@dataclass() -class ConfirmSourceProcessed(Event): - """ - Confirm that the submission source was successfully processed. - - For TeX and PS submissions, this will involve compilation using the AutoTeX - tree. For PDF-only submissions, this may simply involve checking that a - PDF exists. - - If this event has occurred, it indicates that a preview of the submission - content is available. - """ - - NAME = "confirm source has been processed" - NAMED = "confirmed that source has been processed" - - source_id: int = field(default=-1) - """Identifier of the source from which the preview was generated.""" - - source_checksum: str = field(default='') - """Checksum of the source from which the preview was generated.""" - - preview_checksum: str = field(default='') - """Checksum of the preview content itself.""" - - size_bytes: int = field(default=-1) - """Size (in bytes) of the preview content.""" - - added: Optional[datetime] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Make sure that a preview is actually provided.""" - # if self.source_id < 0: - # raise InvalidEvent(self, "Preview not provided") - # if not self.source_checksum: - # raise InvalidEvent(self, 'Missing source checksum') - # if not self.preview_checksum: - # raise InvalidEvent(self, 'Missing preview checksum') - # if not self.size_bytes: - # raise InvalidEvent(self, 'Missing preview size') - # if self.added is None: - # raise InvalidEvent(self, 'Missing added datetime') - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.is_source_processed`.""" - submission.is_source_processed = True - submission.preview = Preview(source_id=self.source_id, # type: ignore - source_checksum=self.source_checksum, - preview_checksum=self.preview_checksum, - size_bytes=self.size_bytes, - added=self.added) - return submission - - -@dataclass() -class UnConfirmSourceProcessed(Event): - """ - Unconfirm that the submission source was successfully processed. - - This can be used to mark a submission as unprocessed even though the - source content has not changed. For example, when reprocessing a - submission. - """ - - NAME = "unconfirm source has been processed" - NAMED = "unconfirmed that source has been processed" - - def validate(self, submission: Submission) -> None: - """Nothing to do.""" - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.is_source_processed`.""" - submission.is_source_processed = False - submission.preview = None - return submission - - -@dataclass() -class ConfirmPreview(Event): - """ - Confirm that the paper and abstract previews are acceptable. - - This event indicates that the submitter has viewed the content preview as - well as the metadata that will be displayed on the abstract page, and - affirms the acceptability of all content. - """ - - NAME = "approve submission preview" - NAMED = "submission preview approved" - - preview_checksum: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Validate data for :class:`.ConfirmPreview`.""" - validators.submission_is_not_finalized(self, submission) - if submission.preview is None: - raise InvalidEvent(self, "Preview not set on submission") - if self.preview_checksum != submission.preview.preview_checksum: - raise InvalidEvent( - self, - f"Checksum {self.preview_checksum} does not match current" - f" preview checksum: {submission.preview.preview_checksum}" - ) - - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.submitter_confirmed_preview`.""" - submission.submitter_confirmed_preview = True - return submission - - -@dataclass(init=False) -class FinalizeSubmission(Event): - """Send the submission to the queue for announcement.""" - - NAME = "finalize submission for announcement" - NAMED = "submission finalized" - - REQUIRED = [ - 'creator', 'primary_classification', 'submitter_contact_verified', - 'submitter_accepts_policy', 'license', 'source_content', 'metadata', - ] - REQUIRED_METADATA = ['title', 'abstract', 'authors_display'] - - def validate(self, submission: Submission) -> None: - """Ensure that all required data/steps are complete.""" - if submission.is_finalized: - raise InvalidEvent(self, "Submission already finalized") - if not submission.is_active: - raise InvalidEvent(self, "Submission must be active") - self._required_fields_are_complete(submission) - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.is_finalized`.""" - submission.status = Submission.SUBMITTED - submission.submitted = datetime.now(UTC) - return submission - - def _required_fields_are_complete(self, submission: Submission) -> None: - """Verify that all required fields are complete.""" - for key in self.REQUIRED: - if not getattr(submission, key): - raise InvalidEvent(self, f"Missing {key}") - for key in self.REQUIRED_METADATA: - if not getattr(submission.metadata, key): - raise InvalidEvent(self, f"Missing {key}") - - -@dataclass() -class UnFinalizeSubmission(Event): - """Withdraw the submission from the queue for announcement.""" - - NAME = "re-open submission for modification" - NAMED = "submission re-opened for modification" - - def validate(self, submission: Submission) -> None: - """Validate the unfinalize action.""" - self._must_be_finalized(submission) - if submission.is_announced: - raise InvalidEvent(self, "Cannot unfinalize an announced paper") - - def _must_be_finalized(self, submission: Submission) -> None: - """May only unfinalize a finalized submission.""" - if not submission.is_finalized: - raise InvalidEvent(self, "Submission is not finalized") - - def project(self, submission: Submission) -> Submission: - """Set :attr:`Submission.is_finalized`.""" - submission.status = Submission.WORKING - submission.submitted = None - return submission - - -@dataclass() -class Announce(Event): - """Announce the current version of the submission.""" - - NAME = "publish submission" - NAMED = "submission announced" - - arxiv_id: Optional[str] = None - - def validate(self, submission: Submission) -> None: - """Make sure that we have a valid arXiv ID.""" - # TODO: When we're using this to perform publish in NG, we will want to - # re-enable this step. - # - # if not submission.status == Submission.SUBMITTED: - # raise InvalidEvent(self, - # "Can't publish in state %s" % submission.status) - # if self.arxiv_id is None: - # raise InvalidEvent(self, "Must provide an arXiv ID.") - # try: - # arxiv_identifier.parse_arxiv_id(self.arxiv_id) - # except ValueError: - # raise InvalidEvent(self, "Not a valid arXiv ID.") - - def project(self, submission: Submission) -> Submission: - """Set the arXiv ID on the submission.""" - submission.arxiv_id = self.arxiv_id - submission.status = Submission.ANNOUNCED - submission.versions.append(copy.deepcopy(submission)) - return submission - - -# Moderation-related events. - - -# @dataclass() -# class CreateComment(Event): -# """Creation of a :class:`.Comment` on a :class:`.domain.submission.Submission`.""" -# -# read_scope = 'submission:moderate' -# write_scope = 'submission:moderate' -# -# body: str = field(default_factory=str) -# scope: str = 'private' -# -# def validate(self, submission: Submission) -> None: -# """The :attr:`.body` should be set.""" -# if not self.body: -# raise ValueError('Comment body not set') -# -# def project(self, submission: Submission) -> Submission: -# """Create a new :class:`.Comment` and attach it to the submission.""" -# submission.comments[self.event_id] = Comment( -# event_id=self.event_id, -# creator=self.creator, -# created=self.created, -# proxy=self.proxy, -# submission=submission, -# body=self.body -# ) -# return submission -# -# -# @dataclass() -# class DeleteComment(Event): -# """Deletion of a :class:`.Comment` on a :class:`.domain.submission.Submission`.""" -# -# read_scope = 'submission:moderate' -# write_scope = 'submission:moderate' -# -# comment_id: str = field(default_factory=str) -# -# def validate(self, submission: Submission) -> None: -# """The :attr:`.comment_id` must present on the submission.""" -# if self.comment_id is None: -# raise InvalidEvent(self, 'comment_id is required') -# if not hasattr(submission, 'comments') or not submission.comments: -# raise InvalidEvent(self, 'Cannot delete nonexistant comment') -# if self.comment_id not in submission.comments: -# raise InvalidEvent(self, 'Cannot delete nonexistant comment') -# -# def project(self, submission: Submission) -> Submission: -# """Remove the comment from the submission.""" -# del submission.comments[self.comment_id] -# return submission -# -# -# @dataclass() -# class AddDelegate(Event): -# """Owner delegates authority to another agent.""" -# -# delegate: Optional[Agent] = None -# -# def validate(self, submission: Submission) -> None: -# """The event creator must be the owner of the submission.""" -# if not self.creator == submission.owner: -# raise InvalidEvent(self, 'Event creator must be submission owner') -# -# def project(self, submission: Submission) -> Submission: -# """Add the delegate to the submission.""" -# delegation = Delegation( -# creator=self.creator, -# delegate=self.delegate, -# created=self.created -# ) -# submission.delegations[delegation.delegation_id] = delegation -# return submission -# -# -# @dataclass() -# class RemoveDelegate(Event): -# """Owner revokes authority from another agent.""" -# -# delegation_id: str = field(default_factory=str) -# -# def validate(self, submission: Submission) -> None: -# """The event creator must be the owner of the submission.""" -# if not self.creator == submission.owner: -# raise InvalidEvent(self, 'Event creator must be submission owner') -# -# def project(self, submission: Submission) -> Submission: -# """Remove the delegate from the submission.""" -# if self.delegation_id in submission.delegations: -# del submission.delegations[self.delegation_id] -# return submission - - -@dataclass() -class AddFeature(Event): - """Add feature metadata to a submission.""" - - NAME = "add feature metadata" - NAMED = "feature metadata added" - - feature_type: Feature.Type = \ - field(default=Feature.Type.WORD_COUNT) - feature_value: Union[float, int] = field(default=0) - - def validate(self, submission: Submission) -> None: - """Verify that the feature type is a known value.""" - if self.feature_type not in Feature.Type: - valid_types = ", ".join([ft.value for ft in Feature.Type]) - raise InvalidEvent(self, "Must be one of %s" % valid_types) - - def project(self, submission: Submission) -> Submission: - """Add the annotation to the submission.""" - assert self.created is not None - submission.annotations[self.event_id] = Feature( - event_id=self.event_id, - creator=self.creator, - created=self.created, - proxy=self.proxy, - feature_type=self.feature_type, - feature_value=self.feature_value - ) - return submission - - -@dataclass() -class AddClassifierResults(Event): - """Add the results of a classifier to a submission.""" - - NAME = "add classifer results" - NAMED = "classifier results added" - - classifier: ClassifierResults.Classifiers \ - = field(default=ClassifierResults.Classifiers.CLASSIC) - results: List[ClassifierResult] = field(default_factory=list) - - def validate(self, submission: Submission) -> None: - """Verify that the classifier is a known value.""" - if self.classifier not in ClassifierResults.Classifiers: - valid = ", ".join([c.value for c in ClassifierResults.Classifiers]) - raise InvalidEvent(self, "Must be one of %s" % valid) - - def project(self, submission: Submission) -> Submission: - """Add the annotation to the submission.""" - assert self.created is not None - submission.annotations[self.event_id] = ClassifierResults( - event_id=self.event_id, - creator=self.creator, - created=self.created, - proxy=self.proxy, - classifier=self.classifier, - results=self.results - ) - return submission - - -@dataclass() -class Reclassify(Event): - """Reclassify a submission.""" - - NAME = "reclassify submission" - NAMED = "submission reclassified" - - category: Optional[taxonomy.Category] = None - - def validate(self, submission: Submission) -> None: - """Validate the primary classification category.""" - assert isinstance(self.category, str) - validators.must_be_an_active_category(self, self.category, submission) - self._must_be_unannounced(submission) - validators.cannot_be_secondary(self, self.category, submission) - - def _must_be_unannounced(self, submission: Submission) -> None: - """Can only be set on the first version before publication.""" - if submission.arxiv_id is not None or submission.version > 1: - raise InvalidEvent(self, "Can only be set on the first version," - " before publication.") - - def project(self, submission: Submission) -> Submission: - """Set :attr:`.domain.Submission.primary_classification`.""" - clsn = Classification(category=self.category) - submission.primary_classification = clsn - return submission diff --git a/src/arxiv/submission/domain/event/base.py b/src/arxiv/submission/domain/event/base.py deleted file mode 100644 index 8cdb32a..0000000 --- a/src/arxiv/submission/domain/event/base.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Provides the base event class.""" - -import copy -import hashlib -from collections import defaultdict -from datetime import datetime -from functools import wraps -from typing import Optional, Callable, Tuple, Iterable, List, ClassVar, \ - Mapping, Type, Any, overload - -from dataclasses import field, asdict -from flask import current_app -from pytz import UTC - -from arxiv.base import logging -from arxiv.base.globals import get_application_config - -from ...exceptions import InvalidEvent -from ..agent import Agent, System, agent_factory -from ..submission import Submission -from ..util import get_tzaware_utc_now -from .util import dataclass -from .versioning import EventData, map_to_current_version - -logger = logging.getLogger(__name__) -logger.propagate = False - -Events = Iterable['Event'] -Condition = Callable[['Event', Optional[Submission], Submission], bool] -Callback = Callable[['Event', Optional[Submission], Submission], Events] -Decorator = Callable[[Callable], Callable] -Rule = Tuple[Condition, Callback] -Store = Callable[['Event', Optional[Submission], Submission], - Tuple['Event', Submission]] - - -class EventType(type): - """Metaclass for :class:`.Event`\.""" - - -@dataclass() -class Event(metaclass=EventType): - """ - Base class for submission-related events/commands. - - An event represents a change to a :class:`.domain.submission.Submission`. - Rather than changing submissions directly, an application should create - (and store) events. Each event class must inherit from this base class, - extend it with whatever data is needed for the event, and define methods - for validation and projection (changing a submission): - - - ``validate(self, submission: Submission) -> None`` should raise - :class:`.InvalidEvent` if the event instance has invalid data. - - ``project(self, submission: Submission) -> Submission`` should perform - changes to the :class:`.domain.submission.Submission` and return it. - - An event class also provides a hook for doing things automatically when the - submission changes. To register a function that gets called when an event - is committed, use the :func:`bind` method. - """ - - NAME = 'base event' - NAMED = 'base event' - - creator: Agent - """ - The agent responsible for the operation represented by this event. - - This is **not** necessarily the creator of the submission. - """ - - created: Optional[datetime] = field(default=None) # get_tzaware_utc_now - """The timestamp when the event was originally committed.""" - - proxy: Optional[Agent] = field(default=None) - """ - The agent who facilitated the operation on behalf of the :attr:`.creator`. - - This may be an API client, or another user who has been designated as a - proxy. Note that proxy implies that the creator was not directly involved. - """ - - client: Optional[Agent] = field(default=None) - """ - The client through which the :attr:`.creator` performed the operation. - - If the creator was directly involved in the operation, this property should - be the client that facilitated the operation. - """ - - submission_id: Optional[int] = field(default=None) - """ - The primary identifier of the submission being operated upon. - - This is defined as optional to support creation events, and to facilitate - chaining of events with creation events in the same transaction. - """ - - committed: bool = field(default=False) - """ - Indicates whether the event has been committed to the database. - - This should generally not be set from outside this package. - """ - - before: Optional[Submission] = None - """The state of the submission prior to the event.""" - - after: Optional[Submission] = None - """The state of the submission after the event.""" - - event_type: str = field(default_factory=str) - event_version: str = field(default_factory=str) - - _hooks: ClassVar[Mapping[type, List[Rule]]] = defaultdict(list) - - def __post_init__(self) -> None: - """Make sure data look right.""" - self.event_type = self.get_event_type() - self.event_version = self.get_event_version() - if self.client and isinstance(self.client, dict): - self.client = agent_factory(**self.client) - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - if self.before and isinstance(self.before, dict): - self.before = Submission(**self.before) - if self.after and isinstance(self.after, dict): - self.after = Submission(**self.after) - - @staticmethod - def get_event_version() -> str: - return str(get_application_config().get('CORE_VERSION', '0.0.0')) - - @classmethod - def get_event_type(cls) -> str: - """Get the name of the event type.""" - return cls.__name__ - - @property - def event_id(self) -> str: - """Unique ID for this event.""" - if not self.created: - raise RuntimeError('Event not yet committed') - return self.get_id(self.created, self.event_type, self.creator) - - @staticmethod - def get_id(created: datetime, event_type: str, creator: Agent) -> str: - h = hashlib.new('sha1') - h.update(b'%s:%s:%s' % (created.isoformat().encode('utf-8'), - event_type.encode('utf-8'), - creator.agent_identifier.encode('utf-8'))) - return h.hexdigest() - - def apply(self, submission: Optional[Submission] = None) -> Submission: - """Apply the projection for this :class:`.Event` instance.""" - self.before = copy.deepcopy(submission) - # See comment on CreateSubmission, below. - self.validate(submission) # type: ignore - if submission is not None: - self.after = self.project(copy.deepcopy(submission)) - else: # See comment on CreateSubmission, below. - self.after = self.project(None) # type: ignore - assert self.after is not None - self.after.updated = self.created - - # Make sure that the submission has its own ID, if we know what it is. - if self.after.submission_id is None and self.submission_id is not None: - self.after.submission_id = self.submission_id - if self.submission_id is None and self.after.submission_id is not None: - self.submission_id = self.after.submission_id - return self.after - - @classmethod - def bind(cls, condition: Optional[Condition] = None) -> Decorator: - """ - Generate a decorator to bind a callback to an event type. - - To register a function that will be called whenever an event is - committed, decorate it like so: - - .. code-block:: python - - @MyEvent.bind() - def say_hello(event: MyEvent, before: Submission, - after: Submission) -> Iterable[Event]: - yield SomeOtherEvent(...) - - The callback function will be passed the event that triggered it, the - state of the submission before and after the triggering event was - applied, and a :class:`.System` agent that can be used as the creator - of subsequent events. It should return an iterable of other - :class:`.Event` instances, either by yielding them, or by - returning an iterable object of some kind. - - By default, callbacks will only be called if the creator of the - trigger event is not a :class:`.System` instance. This makes it less - easy to define infinite chains of callbacks. You can pass a custom - condition to the decorator, for example: - - .. code-block:: python - - def jill_created_an_event(event: MyEvent, before: Submission, - after: Submission) -> bool: - return event.creator.username == 'jill' - - - @MyEvent.bind(jill_created_an_event) - def say_hi(event: MyEvent, before: Submission, - after: Submission) -> Iterable[Event]: - yield SomeOtherEvent(...) - - Note that the condition signature is ``(event: MyEvent, before: - Submission, after: Submission) -> bool``\. - - Parameters - ---------- - condition : Callable - A callable with the signature ``(event: Event, before: Submission, - after: Submission) -> bool``. If this callable returns ``True``, - the callback will be triggered when the event to which it is bound - is saved. The default condition is that the event was not created - by :class:`System` - - Returns - ------- - Callable - Decorator for a callback function, with signature ``(event: Event, - before: Submission, after: Submission, creator: Agent = - System(...)) -> Iterable[Event]``. - - """ - if condition is None: - def _creator_is_not_system(e: Event, *ar: Any, **kw: Any) -> bool: - return type(e.creator) is not System - condition = _creator_is_not_system - - def decorator(func: Callback) -> Callback: - """Register a callback for an event type and condition.""" - name = f'{cls.__name__}::{func.__module__}.{func.__name__}' - sys = System(name) - setattr(func, '__name__', name) - - @wraps(func) - def do(event: Event, before: Submission, after: Submission, - creator: Agent = sys, **kwargs: Any) -> Iterable['Event']: - """Perform the callback. Here in case we need to hook in.""" - return func(event, before, after) - - assert condition is not None - cls._add_callback(condition, do) - return do - return decorator - - @classmethod - def _add_callback(cls: Type['Event'], condition: Condition, - callback: Callback) -> None: - cls._hooks[cls].append((condition, callback)) - - def _get_callbacks(self) -> Iterable[Tuple[Condition, Callback]]: - return ((condition, callback) for cls in type(self).__mro__[::-1] - for condition, callback in self._hooks[cls]) - - def _should_apply_callbacks(self) -> bool: - config = get_application_config() - return bool(int(config.get('ENABLE_CALLBACKS', '0'))) - - def validate(self, submission: Submission) -> None: - """Validate this event and its data against a submission.""" - raise NotImplementedError('Must be implemented by subclass') - - def project(self, submission: Submission) -> Submission: - """Apply this event and its data to a submission.""" - raise NotImplementedError('Must be implemented by subclass') - - def commit(self, store: Store) -> Tuple[Submission, Events]: - """ - Persist this event instance using an injected store method. - - Parameters - ---------- - save : Callable - Should have signature ``(*Event, submission_id: int) -> - Tuple[Event, Submission]``. - - Returns - ------- - :class:`Submission` - State of the submission after storage. Some changes may have been - made to ensure consistency with the underlying datastore. - list - Items are :class:`Event` instances. - - """ - assert self.after is not None - _, after = store(self, self.before, self.after) - self.committed = True - if not self._should_apply_callbacks(): - return self.after, [] - consequences: List[Event] = [] - for condition, callback in self._get_callbacks(): - assert self.after is not None - if condition(self, self.before, self.after): - for consequence in callback(self, self.before, self.after): - consequence.created = datetime.now(UTC) - self.after = consequence.apply(self.after) - consequences.append(consequence) - self.after, addl_consequences = consequence.commit(store) - for addl in addl_consequences: - consequences.append(addl) - assert self.after is not None - return self.after, consequences - - -def _get_subclasses(klass: Type[Event]) -> List[Type[Event]]: - _subclasses = klass.__subclasses__() - if _subclasses: - return _subclasses + [sub for klass in _subclasses - for sub in _get_subclasses(klass)] - return _subclasses - - -def event_factory(event_type: str, created: datetime, **data: Any) -> Event: - """ - Generate an :class:`Event` instance from raw :const:`EventData`. - - Parameters - ---------- - event_type : str - Should be the name of a :class:`.Event` subclass. - data : kwargs - Keyword parameters passed to the event constructor. - - Returns - ------- - :class:`.Event` - An instance of an :class:`.Event` subclass. - - """ - etypes = {klas.get_event_type(): klas for klas in _get_subclasses(Event)} - # TODO: typing on version_data is not very good right now. This is not an - # error, but we have two competing ways of using the data that gets passed - # in that need to be reconciled. - version_data: EventData = data # type: ignore - version_data.update({'event_type': event_type}) - data = map_to_current_version(version_data) # type: ignore - event_version = data.pop("event_version", None) - data['created'] = created - if event_type in etypes: - # Mypy gives a spurious 'Too many arguments for "Event"'. - return etypes[event_type](**data) # type: ignore - raise RuntimeError('Unknown event type: %s' % event_type) diff --git a/src/arxiv/submission/domain/event/flag.py b/src/arxiv/submission/domain/event/flag.py deleted file mode 100644 index 6d9515e..0000000 --- a/src/arxiv/submission/domain/event/flag.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Events/commands related to quality assurance.""" - -from typing import Optional, Union - -from dataclasses import field - -from .util import dataclass -from .base import Event -from ..flag import Flag, ContentFlag, MetadataFlag, UserFlag -from ..submission import Submission, SubmissionMetadata, Hold, Waiver -from ...exceptions import InvalidEvent - - -@dataclass() -class AddFlag(Event): - """Base class for flag events; not for direct use.""" - - NAME = "add flag" - NAMED = "flag added" - - flag_data: Optional[Union[int, str, float, dict, list]] \ - = field(default=None) - comment: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Not implemented.""" - raise NotImplementedError("Invoke a child event instead") - - def project(self, submission: Submission) -> Submission: - """Not implemented.""" - raise NotImplementedError("Invoke a child event instead") - - -@dataclass() -class RemoveFlag(Event): - """Remove a :class:`.domain.Flag` from a submission.""" - - NAME = "remove flag" - NAMED = "flag removed" - - flag_id: Optional[str] = field(default=None) - """This is the ``event_id`` of the event that added the flag.""" - - def validate(self, submission: Submission) -> None: - """Verify that the flag exists.""" - if self.flag_id not in submission.flags: - raise InvalidEvent(self, f"Unknown flag: {self.flag_id}") - - def project(self, submission: Submission) -> Submission: - """Remove the flag from the submission.""" - assert self.flag_id is not None - submission.flags.pop(self.flag_id) - return submission - - -@dataclass() -class AddContentFlag(AddFlag): - """Add a :class:`.domain.ContentFlag` related to content.""" - - NAME = "add content flag" - NAMED = "content flag added" - - flag_type: Optional[ContentFlag.FlagType] = None - - def validate(self, submission: Submission) -> None: - """Verify that we have a known flag.""" - if self.flag_type not in ContentFlag.FlagType: - raise InvalidEvent(self, f"Unknown content flag: {self.flag_type}") - - def project(self, submission: Submission) -> Submission: - """Add the flag to the submission.""" - assert self.created is not None - submission.flags[self.event_id] = ContentFlag( - event_id=self.event_id, - created=self.created, - creator=self.creator, - proxy=self.proxy, - flag_type=self.flag_type, - flag_data=self.flag_data, - comment=self.comment or '' - ) - return submission - - def __post_init__(self) -> None: - """Make sure that `flag_type` is an enum instance.""" - if type(self.flag_type) is str: - self.flag_type = ContentFlag.FlagType(self.flag_type) - super(AddContentFlag, self).__post_init__() - - -@dataclass() -class AddMetadataFlag(AddFlag): - """Add a :class:`.domain.MetadataFlag` related to the metadata.""" - - NAME = "add metadata flag" - NAMED = "metadata flag added" - - flag_type: Optional[MetadataFlag.FlagType] = field(default=None) - field: Optional[str] = field(default=None) - """Name of the metadata field to which the flag applies.""" - - def validate(self, submission: Submission) -> None: - """Verify that we have a known flag and metadata field.""" - if self.flag_type not in MetadataFlag.FlagType: - raise InvalidEvent(self, f"Unknown meta flag: {self.flag_type}") - if self.field and not hasattr(SubmissionMetadata, self.field): - raise InvalidEvent(self, "Not a valid metadata field") - - def project(self, submission: Submission) -> Submission: - """Add the flag to the submission.""" - assert self.created is not None - submission.flags[self.event_id] = MetadataFlag( - event_id=self.event_id, - created=self.created, - creator=self.creator, - proxy=self.proxy, - flag_type=self.flag_type, - flag_data=self.flag_data, - comment=self.comment or '', - field=self.field - ) - return submission - - def __post_init__(self) -> None: - """Make sure that `flag_type` is an enum instance.""" - if type(self.flag_type) is str: - self.flag_type = MetadataFlag.FlagType(self.flag_type) - super(AddMetadataFlag, self).__post_init__() - - -@dataclass() -class AddUserFlag(AddFlag): - """Add a :class:`.domain.UserFlag` related to the submitter.""" - - NAME = "add user flag" - NAMED = "user flag added" - - flag_type: Optional[UserFlag.FlagType] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Verify that we have a known flag.""" - if self.flag_type not in MetadataFlag.FlagType: - raise InvalidEvent(self, f"Unknown user flag: {self.flag_type}") - - def project(self, submission: Submission) -> Submission: - """Add the flag to the submission.""" - assert self.flag_type is not None - assert self.created is not None - submission.flags[self.event_id] = UserFlag( - event_id=self.event_id, - created=self.created, - creator=self.creator, - flag_type=self.flag_type, - flag_data=self.flag_data, - comment=self.comment or '' - ) - return submission - - def __post_init__(self) -> None: - """Make sure that `flag_type` is an enum instance.""" - if type(self.flag_type) is str: - self.flag_type = UserFlag.FlagType(self.flag_type) - super(AddUserFlag, self).__post_init__() - - -@dataclass() -class AddHold(Event): - """Add a :class:`.Hold` to a :class:`.Submission`.""" - - NAME = "add hold" - NAMED = "hold added" - - hold_type: Hold.Type = field(default=Hold.Type.PATCH) - hold_reason: Optional[str] = field(default_factory=str) - - def validate(self, submission: Submission) -> None: - pass - - def project(self, submission: Submission) -> Submission: - """Add the hold to the submission.""" - assert self.created is not None - submission.holds[self.event_id] = Hold( - event_id=self.event_id, - created=self.created, - creator=self.creator, - hold_type=self.hold_type, - hold_reason=self.hold_reason - ) - # submission.status = Submission.ON_HOLD - return submission - - def __post_init__(self) -> None: - """Make sure that `hold_type` is an enum instance.""" - if type(self.hold_type) is str: - self.hold_type = Hold.Type(self.hold_type) - super(AddHold, self).__post_init__() - - -@dataclass() -class RemoveHold(Event): - """Remove a :class:`.Hold` from a :class:`.Submission`.""" - - NAME = "remove hold" - NAMED = "hold removed" - - hold_event_id: str = field(default_factory=str) - hold_type: Hold.Type = field(default=Hold.Type.PATCH) - removal_reason: Optional[str] = field(default_factory=str) - - def validate(self, submission: Submission) -> None: - if self.hold_event_id not in submission.holds: - raise InvalidEvent(self, "No such hold") - - def project(self, submission: Submission) -> Submission: - """Remove the hold from the submission.""" - submission.holds.pop(self.hold_event_id) - # submission.status = Submission.SUBMITTED - return submission - - def __post_init__(self) -> None: - """Make sure that `hold_type` is an enum instance.""" - if type(self.hold_type) is str: - self.hold_type = Hold.Type(self.hold_type) - super(RemoveHold, self).__post_init__() - - -@dataclass() -class AddWaiver(Event): - """Add a :class:`.Waiver` to a :class:`.Submission`.""" - - NAME = "add waiver" - NAMED = "waiver added" - - waiver_type: Hold.Type = field(default=Hold.Type.SOURCE_OVERSIZE) - waiver_reason: str = field(default_factory=str) - - def validate(self, submission: Submission) -> None: - pass - - def project(self, submission: Submission) -> Submission: - """Add the :class:`.Waiver` to the :class:`.Submission`.""" - assert self.created is not None - submission.waivers[self.event_id] = Waiver( - event_id=self.event_id, - created=self.created, - creator=self.creator, - waiver_type=self.waiver_type, - waiver_reason=self.waiver_reason - ) - return submission - - def __post_init__(self) -> None: - """Make sure that `waiver_type` is an enum instance.""" - if type(self.waiver_type) is str: - self.waiver_type = Hold.Type(self.waiver_type) - super(AddWaiver, self).__post_init__() diff --git a/src/arxiv/submission/domain/event/process.py b/src/arxiv/submission/domain/event/process.py deleted file mode 100644 index 51fa900..0000000 --- a/src/arxiv/submission/domain/event/process.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Events related to external or long-running processes.""" - -from typing import Optional - -from dataclasses import field - -from ...exceptions import InvalidEvent -from ..submission import Submission -from ..process import ProcessStatus -from .base import Event -from .util import dataclass - - -@dataclass() -class AddProcessStatus(Event): - """Add the status of an external/long-running process to a submission.""" - - NAME = "add status of a process" - NAMED = "added status of a process" - - # Status = ProcessStatus.Status - - process_id: Optional[str] = field(default=None) - process: Optional[str] = field(default=None) - step: Optional[str] = field(default=None) - status: ProcessStatus.Status = field(default=ProcessStatus.Status.PENDING) - reason: Optional[str] = field(default=None) - - def __post_init__(self) -> None: - """Make sure our enums are in order.""" - super(AddProcessStatus, self).__post_init__() - self.status = ProcessStatus.Status(self.status) - - def validate(self, submission: Submission) -> None: - """Verify that we have a :class:`.ProcessStatus`.""" - if self.process is None: - raise InvalidEvent(self, "Must include process") - - def project(self, submission: Submission) -> Submission: - """Add the process status to the submission.""" - assert self.created is not None - assert self.process is not None - submission.processes.append(ProcessStatus( - creator=self.creator, - created=self.created, - process=self.process, - step=self.step, - status=self.status, - reason=self.reason - )) - return submission diff --git a/src/arxiv/submission/domain/event/proposal.py b/src/arxiv/submission/domain/event/proposal.py deleted file mode 100644 index 76194c8..0000000 --- a/src/arxiv/submission/domain/event/proposal.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Commands for working with :class:`.Proposal` instances on submissions.""" - -import hashlib -import re -import copy -from datetime import datetime -from pytz import UTC -from typing import Optional, TypeVar, List, Tuple, Any, Dict, Iterable -from urllib.parse import urlparse -from dataclasses import field, asdict -from .util import dataclass -import bleach - -from arxiv import taxonomy -from arxiv.base import logging - -from ..agent import Agent -from ..submission import Submission, SubmissionMetadata, Author, \ - Classification, License, Delegation, \ - SubmissionContent, WithdrawalRequest, CrossListClassificationRequest -from ..proposal import Proposal -from ..annotation import Comment - -from ...exceptions import InvalidEvent -from ..util import get_tzaware_utc_now -from .base import Event -from .request import RequestCrossList, RequestWithdrawal, ApplyRequest, \ - RejectRequest, ApproveRequest -from . import validators - -logger = logging.getLogger(__name__) - - -@dataclass() -class AddProposal(Event): - """Add a new proposal to a :class:`Submission`.""" - - NAME = 'add proposal' - NAMED = 'proposal added' - - proposed_event_type: Optional[type] = field(default=None) - proposed_event_data: dict = field(default_factory=dict) - comment: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Simulate applying the proposal to check for validity.""" - if self.proposed_event_type is None: - raise InvalidEvent(self, f"Proposed event type is required") - proposed_event_data = copy.deepcopy(self.proposed_event_data) - proposed_event_data.update({'creator': self.creator}) - event = self.proposed_event_type(**proposed_event_data) - event.validate(submission) - - def project(self, submission: Submission) -> Submission: - """Add the proposal to the submission.""" - assert self.created is not None - submission.proposals[self.event_id] = Proposal( - event_id=self.event_id, - creator=self.creator, - created=self.created, - proxy=self.proxy, - proposed_event_type=self.proposed_event_type, - proposed_event_data=self.proposed_event_data, - comments=[Comment(event_id=self.event_id, creator=self.creator, - created=self.created, proxy=self.proxy, - body=self.comment or '')], - status=Proposal.Status.PENDING - ) - return submission - - -@dataclass() -class RejectProposal(Event): - """Reject a :class:`.Proposal` on a submission.""" - - NAME = 'reject proposal' - NAMED = 'proposal rejected' - - proposal_id: Optional[str] = field(default=None) - comment: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Ensure that the proposal isn't already approved or rejected.""" - if self.proposal_id not in submission.proposals: - raise InvalidEvent(self, f"No such proposal {self.proposal_id}") - elif submission.proposals[self.proposal_id].is_rejected(): - raise InvalidEvent(self, f"{self.proposal_id} is already rejected") - elif submission.proposals[self.proposal_id].is_accepted(): - raise InvalidEvent(self, f"{self.proposal_id} is accepted") - - def project(self, submission: Submission) -> Submission: - """Set the status of the proposal to rejected.""" - assert self.proposal_id is not None - assert self.created is not None - submission.proposals[self.proposal_id].status = Proposal.Status.REJECTED - if self.comment: - submission.proposals[self.proposal_id].comments.append( - Comment(event_id=self.event_id, creator=self.creator, - created=self.created, proxy=self.proxy, - body=self.comment)) - return submission - - -@dataclass() -class AcceptProposal(Event): - """Accept a :class:`.Proposal` on a submission.""" - - NAME = 'accept proposal' - NAMED = 'proposal accepted' - - proposal_id: Optional[str] = field(default=None) - comment: Optional[str] = field(default=None) - - def validate(self, submission: Submission) -> None: - """Ensure that the proposal isn't already approved or rejected.""" - if self.proposal_id not in submission.proposals: - raise InvalidEvent(self, f"No such proposal {self.proposal_id}") - elif submission.proposals[self.proposal_id].is_rejected(): - raise InvalidEvent(self, f"{self.proposal_id} is rejected") - elif submission.proposals[self.proposal_id].is_accepted(): - raise InvalidEvent(self, f"{self.proposal_id} is already accepted") - - def project(self, submission: Submission) -> Submission: - """Mark the proposal as accepted.""" - assert self.created is not None - assert self.proposal_id is not None - submission.proposals[self.proposal_id].status \ - = Proposal.Status.ACCEPTED - if self.comment: - submission.proposals[self.proposal_id].comments.append( - Comment(event_id=self.event_id, creator=self.creator, - created=self.created, proxy=self.proxy, - body=self.comment)) - return submission - - -@AcceptProposal.bind() -def apply_proposal(event: AcceptProposal, before: Submission, - after: Submission, creator: Agent) -> Iterable[Event]: - """Apply an accepted proposal.""" - assert event.proposal_id is not None - proposal = after.proposals[event.proposal_id] - proposed_event_data = copy.deepcopy(proposal.proposed_event_data) - proposed_event_data.update({'creator': creator}) - - assert proposal.proposed_event_type is not None - event = proposal.proposed_event_type(**proposed_event_data) - yield event diff --git a/src/arxiv/submission/domain/event/request.py b/src/arxiv/submission/domain/event/request.py deleted file mode 100644 index 3b09762..0000000 --- a/src/arxiv/submission/domain/event/request.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Commands/events related to user requests.""" - -from typing import Optional, List -import hashlib -from dataclasses import field -from .util import dataclass - -from arxiv import taxonomy - -from . import validators -from .base import Event -from ..submission import Submission, Classification, WithdrawalRequest, \ - CrossListClassificationRequest, UserRequest -from ...exceptions import InvalidEvent - - -@dataclass() -class ApproveRequest(Event): - """Approve a user request.""" - - NAME = "approve user request" - NAMED = "user request approved" - - request_id: Optional[str] = field(default=None) - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - if self.request_id not in submission.user_requests: - raise InvalidEvent(self, "No such request") - - def project(self, submission: Submission) -> Submission: - assert self.request_id is not None - submission.user_requests[self.request_id].status = UserRequest.APPROVED - return submission - - -@dataclass() -class RejectRequest(Event): - NAME = "reject user request" - NAMED = "user request rejected" - - request_id: Optional[str] = field(default=None) - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - if self.request_id not in submission.user_requests: - raise InvalidEvent(self, "No such request") - - def project(self, submission: Submission) -> Submission: - assert self.request_id is not None - submission.user_requests[self.request_id].status = UserRequest.REJECTED - return submission - - -@dataclass() -class CancelRequest(Event): - NAME = "cancel user request" - NAMED = "user request cancelled" - - request_id: Optional[str] = field(default=None) - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - if self.request_id not in submission.user_requests: - raise InvalidEvent(self, "No such request") - - def project(self, submission: Submission) -> Submission: - assert self.request_id is not None - submission.user_requests[self.request_id].status = \ - UserRequest.CANCELLED - return submission - - -@dataclass() -class ApplyRequest(Event): - NAME = "apply user request" - NAMED = "user request applied" - - request_id: Optional[str] = field(default=None) - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - if self.request_id not in submission.user_requests: - raise InvalidEvent(self, "No such request") - - def project(self, submission: Submission) -> Submission: - assert self.request_id is not None - user_request = submission.user_requests[self.request_id] - if hasattr(user_request, 'apply'): - submission = user_request.apply(submission) - user_request.status = UserRequest.APPLIED - submission.user_requests[self.request_id] = user_request - return submission - - -@dataclass() -class RequestCrossList(Event): - """Request that a secondary classification be added after announcement.""" - - NAME = "request cross-list classification" - NAMED = "cross-list classification requested" - - categories: List[taxonomy.Category] = field(default_factory=list) - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - """Validate the cross-list request.""" - validators.no_active_requests(self, submission) - if not submission.is_announced: - raise InvalidEvent(self, "Submission must already be announced") - for category in self.categories: - validators.must_be_an_active_category(self, category, submission) - validators.cannot_be_primary(self, category, submission) - validators.cannot_be_secondary(self, category, submission) - - def project(self, submission: Submission) -> Submission: - """Create a cross-list request.""" - classifications = [ - Classification(category=category) for category in self.categories - ] - - req_id = CrossListClassificationRequest.generate_request_id(submission) - assert self.created is not None - user_request = CrossListClassificationRequest( - request_id=req_id, - creator=self.creator, - created=self.created, - status=WithdrawalRequest.PENDING, - classifications=classifications - ) - submission.user_requests[req_id] = user_request - return submission - - -@dataclass() -class RequestWithdrawal(Event): - """Request that a paper be withdrawn.""" - - NAME = "request withdrawal" - NAMED = "withdrawal requested" - - reason: str = field(default_factory=str) - - MAX_LENGTH = 400 - - def __hash__(self) -> int: - """Use event ID as object hash.""" - return hash(self.event_id) - - def __eq__(self, other: object) -> bool: - """Compare this event to another event.""" - if not isinstance(other, Event): - return NotImplemented - return hash(self) == hash(other) - - def validate(self, submission: Submission) -> None: - """Make sure that a reason was provided.""" - validators.no_active_requests(self, submission) - if not self.reason: - raise InvalidEvent(self, "Provide a reason for the withdrawal") - if len(self.reason) > self.MAX_LENGTH: - raise InvalidEvent(self, "Reason must be 400 characters or less") - if not submission.is_announced: - raise InvalidEvent(self, "Submission must already be announced") - - def project(self, submission: Submission) -> Submission: - """Update the submission status and withdrawal reason.""" - assert self.created is not None - req_id = WithdrawalRequest.generate_request_id(submission) - user_request = WithdrawalRequest( - request_id=req_id, - creator=self.creator, - created=self.created, - updated=self.created, - status=WithdrawalRequest.PENDING, - reason_for_withdrawal=self.reason - ) - submission.user_requests[req_id] = user_request - return submission diff --git a/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py b/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py deleted file mode 100644 index 1c4b922..0000000 --- a/src/arxiv/submission/domain/event/tests/test_abstract_cleanup.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Test abstract cleanup""" - -from unittest import TestCase -from .. import SetAbstract -from arxiv.base.filters import abstract_lf_to_br - -class TestSetAbstractCleanup(TestCase): - """Test abstract cleanup""" - - def test_paragraph_cleanup(self): - awlb = "Paragraph 1.\n \nThis should be paragraph 2" - self.assertIn(' in') - - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n\t\nThis should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace (tab)') - - awlb = "Paragraph 1.\n \nThis should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n \t \nThis should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n \nThis should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n This should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n\tThis should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') - - awlb = "Paragraph 1.\n This should be p 2." - e = SetAbstract(creator='xyz', abstract=awlb) - self.assertIn(' creating whitespace') diff --git a/src/arxiv/submission/domain/event/tests/test_event_construction.py b/src/arxiv/submission/domain/event/tests/test_event_construction.py deleted file mode 100644 index 51de87c..0000000 --- a/src/arxiv/submission/domain/event/tests/test_event_construction.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Test that all event classes are well-formed.""" - -from unittest import TestCase -import inspect -from ..base import Event - - -class TestNamed(TestCase): - """Verify that all event classes are named.""" - - def test_has_name(self): - """All event classes must have a ``NAME`` attribute.""" - for klass in Event.__subclasses__(): - self.assertTrue(hasattr(klass, 'NAME'), - f'{klass.__name__} is missing attribute NAME') - - def test_has_named(self): - """All event classes must have a ``NAMED`` attribute.""" - for klass in Event.__subclasses__(): - self.assertTrue(hasattr(klass, 'NAMED'), - f'{klass.__name__} is missing attribute NAMED') - - -class TestHasProjection(TestCase): - """Verify that all event classes have a projection method.""" - - def test_has_projection(self): - """Each event class must have an instance method ``project()``.""" - for klass in Event.__subclasses__(): - self.assertTrue(hasattr(klass, 'project'), - f'{klass.__name__} is missing project() method') - self.assertTrue(inspect.isfunction(klass.project), - f'{klass.__name__} is missing project() method') - - -class TestHasValidation(TestCase): - """Verify that all event classes have a projection method.""" - - def test_has_validate(self): - """Each event class must have an instance method ``validate()``.""" - for klass in Event.__subclasses__(): - self.assertTrue(hasattr(klass, 'validate'), - f'{klass.__name__} is missing validate() method') - self.assertTrue(inspect.isfunction(klass.validate), - f'{klass.__name__} is missing validate() method') diff --git a/src/arxiv/submission/domain/event/tests/test_hooks.py b/src/arxiv/submission/domain/event/tests/test_hooks.py deleted file mode 100644 index aa960a4..0000000 --- a/src/arxiv/submission/domain/event/tests/test_hooks.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Test callback hook functionality on :class:`Event`.""" - -from unittest import TestCase, mock -from dataclasses import dataclass, field -from ..base import Event -from ...agent import System - - -class TestCommitEvent(TestCase): - """Tests for :func:`Event.bind` and :class:`Event.commit`.""" - - def test_commit_event(self): - """Test a simple commit hook.""" - @dataclass - class ChildEvent(Event): - def _should_apply_callbacks(self): - return True - - @dataclass - class OtherChildEvent(Event): - def _should_apply_callbacks(self): - return True - - callback = mock.MagicMock(return_value=[], __name__='test') - ChildEvent.bind(lambda *a: True)(callback) # Register callback. - - save = mock.MagicMock( - return_value=(mock.MagicMock(), mock.MagicMock()) - ) - event = ChildEvent(creator=System('system')) - event.after = mock.MagicMock() - OtherChildEvent(creator=System('system')) - event.commit(save) - self.assertEqual(callback.call_count, 1, - "Callback is only executed on the class to which it" - " is bound") - - def test_callback_inheritance(self): - """Callback is inherited by subclasses.""" - @dataclass - class ParentEvent(Event): - def _should_apply_callbacks(self): - return True - - @dataclass - class ChildEvent(ParentEvent): - def _should_apply_callbacks(self): - return True - - callback = mock.MagicMock(return_value=[], __name__='test') - ParentEvent.bind(lambda *a: True)(callback) # Register callback. - - save = mock.MagicMock( - return_value=(mock.MagicMock(), mock.MagicMock()) - ) - event = ChildEvent(creator=System('system')) - event.after = mock.MagicMock() - event.commit(save) - self.assertEqual(callback.call_count, 1, - "Callback bound to parent class is called when child" - " is committed") diff --git a/src/arxiv/submission/domain/event/util.py b/src/arxiv/submission/domain/event/util.py deleted file mode 100644 index 19e8a78..0000000 --- a/src/arxiv/submission/domain/event/util.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Helpers for event classes.""" - -from typing import Any, Callable - -from dataclasses import dataclass as base_dataclass - - -def event_hash(instance: Any) -> int: - """Use event ID as object hash.""" - return hash(instance.event_id) # typing: ignore - - -def event_eq(instance: Any, other: Any) -> bool: - """Compare this event to another event.""" - return hash(instance) == hash(other) - - -def dataclass(**kwargs: Any) -> Callable[[Any], Any]: - def inner(cls: type) -> Any: - if kwargs: - new_cls = base_dataclass(**kwargs)(cls) - else: - new_cls = base_dataclass(cls) - setattr(new_cls, '__hash__', event_hash) - setattr(new_cls, '__eq__', event_eq) - return new_cls - return inner diff --git a/src/arxiv/submission/domain/event/validators.py b/src/arxiv/submission/domain/event/validators.py deleted file mode 100644 index 406d986..0000000 --- a/src/arxiv/submission/domain/event/validators.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Reusable validators for events.""" - -import re - -from arxiv.taxonomy import CATEGORIES, CATEGORIES_ACTIVE - -from .base import Event -from ..submission import Submission -from ...exceptions import InvalidEvent - - -def submission_is_not_finalized(event: Event, submission: Submission) -> None: - """ - Verify that the submission is not finalized. - - Parameters - ---------- - event : :class:`.Event` - submission : :class:`.domain.submission.Submission` - - Raises - ------ - :class:`.InvalidEvent` - Raised if the submission is finalized. - - """ - if submission.is_finalized: - raise InvalidEvent(event, "Cannot apply to a finalized submission") - - -def no_trailing_period(event: Event, submission: Submission, - value: str) -> None: - """ - Verify that there are no trailing periods in ``value`` except ellipses. - """ - if re.search(r"(? None: - """Valid arXiv categories are defined in :mod:`arxiv.taxonomy`.""" - if not category or category not in CATEGORIES_ACTIVE: - raise InvalidEvent(event, "Not a valid category") - - -def cannot_be_primary(event: Event, category: str, submission: Submission) \ - -> None: - """The category can't already be set as a primary classification.""" - if submission.primary_classification is None: - return - if category == submission.primary_classification.category: - raise InvalidEvent(event, "The same category cannot be used as both" - " the primary and a secondary category.") - - -def cannot_be_secondary(event: Event, category: str, submission: Submission) \ - -> None: - """The same category cannot be added as a secondary twice.""" - if category in submission.secondary_categories: - raise InvalidEvent(event, f"Secondary {category} already set on this" - f" submission.") - - -def no_active_requests(event: Event, submission: Submission) -> None: - """Must not have active requests""" - if submission.has_active_requests: - raise InvalidEvent(event, "Must not have active requests.") - - -def cannot_be_genph(event: Event, category: str, submission: Submission)\ - -> None: - "Cannot be physics.gen-ph." - if category and category == 'physics.gen-ph': - raise InvalidEvent(event, "Cannot be physics.gen-ph.") - - -def no_redundant_general_category(event: Event, - category: str, - submission: Submission) -> None: - """Prevents adding a general category when another category in - that archive is already represented.""" - if CATEGORIES[category]['is_general']: - if((submission.primary_classification and - CATEGORIES[category]['in_archive'] == - CATEGORIES[submission.primary_category]['in_archive']) - or - (CATEGORIES[category]['in_archive'] - in [CATEGORIES[cat]['in_archive'] for - cat in submission.secondary_categories])): - raise InvalidEvent(event, - f"Cannot add general category {category}" - f" due to more specific category from" - f" {CATEGORIES[category]['in_archive']}.") - - -def no_redundant_non_general_category(event: Event, - category: str, - submission: Submission) -> None: - """Prevents adding a category when a general category in that archive - is already represented.""" - if not CATEGORIES[category]['is_general']: - e_archive = CATEGORIES[category]['in_archive'] - if(submission.primary_classification and - e_archive == - CATEGORIES[submission.primary_category]['in_archive'] - and CATEGORIES[submission.primary_category]['is_general']): - raise InvalidEvent(event, - f'Cannot add more specific {category} due' - f' to general primary.') - - sec_archs = [tcat['in_archive'] for tcat in - [CATEGORIES[cat] - for cat in submission.secondary_categories] - if tcat['is_general']] - if e_archive in sec_archs: - raise InvalidEvent(event, - f'Cannot add more spcific {category} due' - f' to general secondaries.') - - -def max_secondaries(event: Event, submission: Submission) -> None: - "No more than 4 secondary categories per submission." - if (submission.secondary_classification and - len(submission.secondary_classification) + 1 > 4): - raise InvalidEvent( - event, "No more than 4 secondary categories per submission.") diff --git a/src/arxiv/submission/domain/event/versioning/__init__.py b/src/arxiv/submission/domain/event/versioning/__init__.py deleted file mode 100644 index 7b163ac..0000000 --- a/src/arxiv/submission/domain/event/versioning/__init__.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Provides on-the-fly versioned migrations for event data. - -The purpose of this module is to facilitate backwards-compatible changes to -the structure of :class:`.domain.event.Event` classes. This problem is similar -to database migrations, except that the "meat" of the event data are dicts -stored as JSON and thus ALTER commands won't get us all that far. - -Writing version mappings -======================== -Any new version of this software that includes changes to existing -event/command classes that would break events from earlier versions **MUST** -include a version mapping module. The module should include a mapping class -(a subclass of :class:`.BaseVersionMapping`) for each event type for which -there are relevant changes. - -See :mod:`.versioning.version_0_0_0_example` for an example. - -Each such class must include an internal ``Meta`` class with its software -version and the name of the event type to which it applies. For example: - -.. code-block:: python - - from ._base import BaseVersionMapping - - class SetAbstractMigration(BaseVersionMapping): - class Meta: - event_version = "0.2.12" # Must be a semver. - event_type = "SetAbstract" - - -In addition, it's a good idea to include some test data that can be used to -verify the behavior of the migration. You can do this by adding a ``tests`` -attribute to ``Meta`` that includes tuples of the form -``(original: EventData, expected: EventData)``. For example: - - -.. code-block:: python - - from ._base import BaseVersionMapping - - class SetAbstractMigration(BaseVersionMapping): - class Meta: - event_version = "0.2.12" # Must be a semver. - event_type = "SetAbstract" - tests = [({"event_version": "0.2.11", "abstract": "very abstract"}, - {"event_version": "0.2.12", "abstract": "more abstract"})] - - -Transformation logic can be implemented for individual fields, or for the event -datum as a whole. - -Transforming individual fields ------------------------------- -Transformers for individual fields may be implemented by -defining instance methods with the name ``transform_{field}`` and the signature -``(self, original: EventData, key: str, value: Any) -> Tuple[str, Any]``. -The return value is the field name and transformed value. Note that the field -name may be altered here, and the original field name will be omitted from the -final transformed representation of the event datum. - -Transforming the datum as a whole ---------------------------------- -A transformer for the datum as a whole may be implemented by defining an -instance method named ``transform`` with the signature -``(self, original: EventData, transformed: EventData) -> EventData``. This is -called **after** the transformers for individual fields; the second positional -argument is the state of the datum at that point, and the first positional -argument is the state of the datum before transformations were applied. -""" - -import copy -from ._base import EventData, BaseVersionMapping, Version - -from arxiv.base.globals import get_application_config - - -def map_to_version(original: EventData, target: str) -> EventData: - """ - Map raw event data to a later version. - - Loads all version mappings for the original event type subsequent to the - version of the software at which the data was created, up to and - includiong the ``target`` version. - - Parameters - ---------- - original : dict - Original event data. - target : str - The target software version. Must be a valid semantic version, i.e. - with major, minor, and patch components. - - Returns - ------- - dict - Data from ``original`` transformed into a representation suitable for - use in the target software version. - - """ - original_version = Version.from_event_data(original) - transformed = copy.deepcopy(original) - for mapping in BaseVersionMapping.__subclasses__(): - if original['event_type'] == mapping.Meta.event_type \ - and Version(mapping.Meta.event_version) <= Version(target) \ - and Version(mapping.Meta.event_version) > original_version: - mapper = mapping() - transformed = mapper(transformed) - return transformed - - -def map_to_current_version(original: EventData) -> EventData: - """ - Map raw event data to the current software version. - - Relies on the ``CORE_VERSION`` parameter in the application configuration. - - Parameters - ---------- - original : dict - Original event data. - - Returns - ------- - dict - Data from ``original`` transformed into a representation suitable for - use in the current software version. - - """ - current_version = get_application_config().get('CORE_VERSION', '0.0.0') - return map_to_version(original, current_version) diff --git a/src/arxiv/submission/domain/event/versioning/_base.py b/src/arxiv/submission/domain/event/versioning/_base.py deleted file mode 100644 index 05a7712..0000000 --- a/src/arxiv/submission/domain/event/versioning/_base.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Provides :class:`.BaseVersionMapping`.""" - -from typing import Optional, Callable, Any, Tuple -from datetime import datetime -from mypy_extensions import TypedDict -import semver - - -class EventData(TypedDict, total=False): - """Raw event data from the event store.""" - - event_version: str - created: datetime - event_type: str - - -class Version(str): - """A semantic version.""" - - @classmethod - def from_event_data(cls, data: EventData) -> 'Version': - """Create a :class:`.Version` from :class:`.EventData`.""" - return cls(data['event_version']) - - def __eq__(self, other: object) -> bool: - """Equality comparison using semantic versioning.""" - if not isinstance(other, str): - return NotImplemented - return bool(semver.compare(self, other) == 0) - - def __lt__(self, other: object) -> bool: - """Less-than comparison using semantic versioning.""" - if not isinstance(other, str): - return NotImplemented - return bool(semver.compare(self, other) < 0) - - def __le__(self, other: object) -> bool: - """Less-than-equals comparison using semantic versioning.""" - if not isinstance(other, str): - return NotImplemented - return bool(semver.compare(self, other) <= 0) - - def __gt__(self, other: object) -> bool: - """Greater-than comparison using semantic versioning.""" - if not isinstance(other, str): - return NotImplemented - return bool(semver.compare(self, other) > 0) - - def __ge__(self, other: object) -> bool: - """Greater-than-equals comparison using semantic versioning.""" - if not isinstance(other, str): - return NotImplemented - return bool(semver.compare(self, other) >= 0) - - -FieldTransformer = Callable[[EventData, str, Any], Tuple[str, Any]] - - -class BaseVersionMapping: - """Base class for version mappings.""" - - _protected = ['event_type', 'event_version', 'created'] - - class Meta: - event_version = None - event_type = None - - def __init__(self) -> None: - """Verify that the instance has required metadata.""" - if not hasattr(self, 'Meta'): - raise NotImplementedError('Missing `Meta` on child class') - if getattr(self.Meta, 'event_version', None) is None: - raise NotImplementedError('Missing version on child class') - if getattr(self.Meta, 'event_type', None) is None: - raise NotImplementedError('Missing event_type on child class') - - def __call__(self, original: EventData) -> EventData: - """Transform some :class:`.EventData`.""" - return self._transform(original) - - @classmethod - def test(cls) -> None: - """Perform tests on the mapping subclass.""" - try: - cls() - except NotImplementedError as e: - raise AssertionError('Not correctly implemented') from e - for original, expected in getattr(cls.Meta, 'tests', []): - assert cls()(original) == expected - try: - semver.parse_version_info(cls.Meta.event_version) - except ValueError as e: - raise AssertionError('Not a valid semantic version') from e - - def _get_field_transformer(self, field: str) -> Optional[FieldTransformer]: - """Get a transformation for a field, if it is defined.""" - tx: Optional[FieldTransformer] \ - = getattr(self, f'transform_{field}', None) - return tx - - def transform(self, orig: EventData, xf: EventData) -> EventData: - """Transform the event data as a whole.""" - return xf # Nothing to do; subclasses can reimplement for fun/profit. - - def _transform(self, original: EventData) -> EventData: - """Perform transformation of event data.""" - transformed = EventData() - for key, value in original.items(): - if key not in self._protected: - field_transformer = self._get_field_transformer(key) - if field_transformer is not None: - key, value = field_transformer(original, key, value) - # Mypy wants they key to be a string literal here, which runs - # against the pattern implemented here. We could consider not - # using a TypedDict. This code is correct for now, just not ideal - # for type-checking. - transformed[key] = value # type: ignore - transformed = self.transform(original, transformed) - assert self.Meta.event_version is not None - transformed['event_version'] = self.Meta.event_version - return transformed diff --git a/src/arxiv/submission/domain/event/versioning/tests/__init__.py b/src/arxiv/submission/domain/event/versioning/tests/__init__.py deleted file mode 100644 index c041a0d..0000000 --- a/src/arxiv/submission/domain/event/versioning/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for versioning mechanisms.""" diff --git a/src/arxiv/submission/domain/event/versioning/tests/test_example.py b/src/arxiv/submission/domain/event/versioning/tests/test_example.py deleted file mode 100644 index 83be437..0000000 --- a/src/arxiv/submission/domain/event/versioning/tests/test_example.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Test the example version mapping module.""" - -from unittest import TestCase - -from .. import map_to_version -from .._base import BaseVersionMapping -from .. import version_0_0_0_example - - -class TestSetTitleExample(TestCase): - """Test the :class:`.version_0_0_0_example.SetTitleExample` mapping.""" - - def test_set_title(self): - """Execute the built-in version mapping tests.""" - version_0_0_0_example.SetTitleExample.test() diff --git a/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py b/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py deleted file mode 100644 index e82c097..0000000 --- a/src/arxiv/submission/domain/event/versioning/tests/test_versioning.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Test versioning of event data.""" - -from unittest import TestCase - -from .. import map_to_version -from .._base import BaseVersionMapping - - -class TitleIsNowCoolTitle(BaseVersionMapping): - """Changes the ``title`` field to ``cool_title``.""" - - class Meta: - """Metadata for this mapping.""" - - event_version = '0.3.5' - event_type = "SetTitle" - tests = [({'event_version': '0.1.1', 'title': 'olde'}, - {'event_version': '0.3.5', 'cool_title': 'olde'})] - - def transform_title(self, original, key, value): - """Rename the `title` field to `cool_title`.""" - return "cool_title", value - - -class TestVersionMapping(TestCase): - """Tests for :func:`.map_to_version`.""" - - def test_map_to_version(self): - """We have data from a previous version and an intermediate mapping.""" - data = { - 'event_version': '0.1.2', - 'event_type': 'SetTitle', - 'title': 'Some olde title' - } - - expected = { - 'event_version': '0.3.5', - 'event_type': 'SetTitle', - 'cool_title': 'Some olde title' - } - self.assertDictEqual(map_to_version(data, '0.4.1'), expected, - "The mapping is applied") - - def test_map_to_version_no_intermediate(self): - """We have data from a previous version and no intermediate mapping.""" - data = { - 'event_version': '0.5.5', - 'event_type': 'SetTitle', - 'cool_title': 'Some olde title' - } - self.assertDictEqual(map_to_version(data, '0.6.7'), data, - "The mapping is not applied") - - def test_data_is_up_to_date(self): - """We have data that is 100% current.""" - data = { - 'event_version': '0.5.5', - 'event_type': 'SetTitle', - 'cool_title': 'Some olde title' - } - self.assertDictEqual(map_to_version(data, '0.5.5'), data, - "The mapping is not applied") - - -class TestVersionMappingTests(TestCase): - """Tests defined in metadata can be run, with the expected result.""" - - def test_test(self): - """Run tests in mapping metadata.""" - class BrokenFitleIsNowCoolTitle(BaseVersionMapping): - """A broken version mapping.""" - - class Meta: - """Metadata for this mapping.""" - - event_version = '0.3.5' - event_type = "SetFitle" - tests = [({'event_version': '0.1.1', 'title': 'olde'}, - {'event_version': '0.3.5', 'cool_title': 'olde'})] - - def transform_title(self, original, key, value): - """Rename the `title` field to `cool_title`.""" - return "fool_title", value - - TitleIsNowCoolTitle.test() - with self.assertRaises(AssertionError): - BrokenFitleIsNowCoolTitle.test() - - def test_version_is_present(self): - """Tests check that version is specified.""" - class MappingWithoutVersion(BaseVersionMapping): - """Mapping that is missing a version.""" - - class Meta: - """Metadata for this mapping.""" - - event_type = "FetBitle" - - with self.assertRaises(AssertionError): - MappingWithoutVersion.test() - - def test_event_type_is_present(self): - """Tests check that event_type is specified.""" - class MappingWithoutEventType(BaseVersionMapping): - """Mapping that is missing an event type.""" - - class Meta: - """Metadata for this mapping.""" - - event_version = "5.3.2" - - with self.assertRaises(AssertionError): - MappingWithoutEventType.test() - - def test_version_is_valid(self): - """Tests check that version is a valid semver.""" - class MappingWithInvalidVersion(BaseVersionMapping): - """Mapping that has an invalid semantic version.""" - - class Meta: - """Metadata for this mapping.""" - - event_version = "52" - event_type = "FetBitle" - - with self.assertRaises(AssertionError): - MappingWithInvalidVersion.test() - - -class TestVersioningModule(TestCase): - def test_loads_mappings(self): - """Loading a version mapping module installs those mappings.""" - from .. import version_0_0_0_example - self.assertIn(version_0_0_0_example.SetTitleExample, - BaseVersionMapping.__subclasses__(), - 'Mappings in an imported module are available for use') diff --git a/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py b/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py deleted file mode 100644 index 7949b6f..0000000 --- a/src/arxiv/submission/domain/event/versioning/version_0_0_0_example.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -An example version mapping module. - -This module gathers together all event mappings for version 0.0.0. - -The mappings in this module will never be used, since there are no -data prior to version 0.0.0. -""" -from typing import Tuple -from ._base import BaseVersionMapping, EventData - -VERSION = '0.0.0' - - -class SetTitleExample(BaseVersionMapping): - """Perform no changes whatsoever to the `title` field.""" - - class Meta: - """Metadata about this mapping.""" - - event_version = VERSION - """All of the mappings in this module are for the same version.""" - - event_type = 'SetTitle' - """This mapping applies to :class:`.domain.event.SetTitle`.""" - - tests = [ - ({'event_version': '0.0.0', 'title': 'The title'}, - {'event_version': '0.0.0', 'title': 'The best title!!'}) - ] - """Expected changes to the ``title`` field.""" - - def transform_title(self, orig: EventData, key: str, val: str) \ - -> Tuple[str, str]: - """Make the title the best.""" - parts = val.split() - return key, " ".join([parts[0], "best"] + parts[1:]) - - def transform(self, orig: EventData, xf: EventData) -> EventData: - """Add some emphasis.""" - ed = EventData() - for k, v in xf.items(): - if isinstance(v, str): - v = f"{v}!!" - ed[k] = v # type: ignore - return ed diff --git a/src/arxiv/submission/domain/flag.py b/src/arxiv/submission/domain/flag.py deleted file mode 100644 index 2eddb8e..0000000 --- a/src/arxiv/submission/domain/flag.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Data structures related to QA.""" - -from datetime import datetime -from enum import Enum -from typing import Optional, Union, Type, Dict, Any - -from dataclasses import field, dataclass, asdict -from mypy_extensions import TypedDict - -from .agent import Agent, agent_factory - - -PossibleDuplicate = TypedDict('PossibleDuplicate', - {'id': int, 'title': str, 'owner': Agent}) - - -@dataclass -class Flag: - """Base class for flags.""" - - class FlagType(Enum): - pass - - event_id: str - creator: Agent - created: datetime - flag_data: Optional[Union[int, str, float, dict, list]] - comment: str - proxy: Optional[Agent] = field(default=None) - flag_datatype: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Set derivative fields.""" - self.flag_datatype = self.__class__.__name__ - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - - -@dataclass -class ContentFlag(Flag): - """A flag related to the content of the submission.""" - - flag_type: Optional['FlagType'] = field(default=None) - - class FlagType(Enum): - """Supported content flags.""" - - LOW_STOP = 'low stopwords' - """Number of stopwords is abnormally low.""" - LOW_STOP_PERCENT = 'low stopword percentage' - """Frequency of stopwords is abnormally low.""" - LANGUAGE = 'language' - """Possibly not English language.""" - CHARACTER_SET = 'character set' - """Possibly excessive use of non-ASCII characters.""" - LINE_NUMBERS = 'line numbers' - """Content has line numbers.""" - - -@dataclass -class MetadataFlag(Flag): - """A flag related to the submission metadata.""" - - flag_type: Optional['FlagType'] = field(default=None) - field: Optional[str] = field(default=None) - - class FlagType(Enum): - """Supported metadata flags.""" - - POSSIBLE_DUPLICATE_TITLE = 'possible duplicate title' - LANGUAGE = 'language' - CHARACTER_SET = 'character_set' - - -@dataclass -class UserFlag(Flag): - """A flag related to the submitter.""" - - flag_type: Optional['FlagType'] = field(default=None) - - class FlagType(Enum): - """Supported user flags.""" - - RATE = 'rate' - - -flag_datatypes: Dict[str, Type[Flag]] = { - 'ContentFlag': ContentFlag, - 'MetadataFlag': MetadataFlag, - 'UserFlag': UserFlag -} - - -def flag_factory(**data: Any) -> Flag: - cls = flag_datatypes[data.pop('flag_datatype')] - if not isinstance(data['flag_type'], cls.FlagType): - data['flag_type'] = cls.FlagType(data['flag_type']) - return cls(**data) diff --git a/src/arxiv/submission/domain/meta.py b/src/arxiv/submission/domain/meta.py deleted file mode 100644 index 030ad3a..0000000 --- a/src/arxiv/submission/domain/meta.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Metadata objects in support of submissions.""" - -from typing import Optional, List -from arxiv.taxonomy import Category -from dataclasses import dataclass, asdict, field - - -@dataclass -class Classification: - """A classification for a :class:`.domain.submission.Submission`.""" - - category: Category - - -@dataclass -class License: - """An license for distribution of the submission.""" - - uri: str - name: Optional[str] = None diff --git a/src/arxiv/submission/domain/preview.py b/src/arxiv/submission/domain/preview.py deleted file mode 100644 index add7345..0000000 --- a/src/arxiv/submission/domain/preview.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Provides :class:`.Preview`.""" -from typing import Optional, IO -from datetime import datetime -from dataclasses import dataclass, field, asdict - - -@dataclass -class Preview: - """Metadata about a submission preview.""" - - source_id: int - """Identifier of the source from which the preview was generated.""" - - source_checksum: str - """Checksum of the source from which the preview was generated.""" - - preview_checksum: str - """Checksum of the preview content itself.""" - - size_bytes: int - """Size (in bytes) of the preview content.""" - - added: datetime - """The datetime when the preview was deposited.""" diff --git a/src/arxiv/submission/domain/process.py b/src/arxiv/submission/domain/process.py deleted file mode 100644 index b3fe1fd..0000000 --- a/src/arxiv/submission/domain/process.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Status information for external or long-running processes.""" - -from typing import Optional -from enum import Enum -from datetime import datetime - -from dataclasses import dataclass, field, asdict - -from .agent import Agent, agent_factory -from .util import get_tzaware_utc_now - - -@dataclass -class ProcessStatus: - """Represents the status of a long-running remote process.""" - - class Status(Enum): - """Supported statuses.""" - - PENDING = 'pending' - """The process is waiting to start.""" - IN_PROGRESS = 'in_progress' - """Process has started, and is running remotely.""" - FAILED_TO_START = 'failed_to_start' - """Could not start the process.""" - FAILED = 'failed' - """The process failed while running.""" - FAILED_TO_END = 'failed_to_end' - """The process ran, but failed to end gracefully.""" - SUCCEEDED = 'succeeded' - """The process ended successfully.""" - TERMINATED = 'terminated' - """The process was terminated, e.g. cancelled by operator.""" - - creator: Agent - created: datetime - """Time when the process status was created (not the process itself).""" - process: str - step: Optional[str] = field(default=None) - status: Status = field(default=Status.PENDING) - reason: Optional[str] = field(default=None) - """Optional context or explanatory details related to the status.""" - - def __post_init__(self) -> None: - """Check our enums and agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - self.status = self.Status(self.status) diff --git a/src/arxiv/submission/domain/proposal.py b/src/arxiv/submission/domain/proposal.py deleted file mode 100644 index f7cb3b2..0000000 --- a/src/arxiv/submission/domain/proposal.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Proposals provide a mechanism for suggesting changes to submissions. - -The primary use-case in the classic submission & moderation system is for -suggesting changes to the primary or cross-list classification. Such proposals -are generated both automatically based on the results of the classifier and -manually by moderators. -""" - -from typing import Optional, Union, List -from datetime import datetime -import hashlib - -from dataclasses import dataclass, asdict, field -from enum import Enum - -from arxiv.taxonomy import Category - -from .annotation import Comment -from .util import get_tzaware_utc_now -from .agent import Agent, agent_factory - - -@dataclass -class Proposal: - """Represents a proposal to apply an event to a submission.""" - - class Status(Enum): - PENDING = 'pending' - REJECTED = 'rejected' - ACCEPTED = 'accepted' - - event_id: str - creator: Agent - created: datetime = field(default_factory=get_tzaware_utc_now) - # scope: str # TODO: document this. - proxy: Optional[Agent] = field(default=None) - - proposed_event_type: Optional[type] = field(default=None) - proposed_event_data: dict = field(default_factory=dict) - comments: List[Comment] = field(default_factory=list) - status: Status = field(default=Status.PENDING) - - @property - def proposal_type(self) -> str: - """Name (str) of the type of annotation.""" - assert self.proposed_event_type is not None - return self.proposed_event_type.__name__ - - def __post_init__(self) -> None: - """Check our enums and agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - self.status = self.Status(self.status) - - def is_rejected(self) -> bool: - return self.status == self.Status.REJECTED - - def is_accepted(self) -> bool: - return self.status == self.Status.ACCEPTED - - def is_pending(self) -> bool: - return self.status == self.Status.PENDING diff --git a/src/arxiv/submission/domain/submission.py b/src/arxiv/submission/domain/submission.py deleted file mode 100644 index f3962b3..0000000 --- a/src/arxiv/submission/domain/submission.py +++ /dev/null @@ -1,534 +0,0 @@ -"""Data structures for submissions.""" - -import hashlib -from enum import Enum -from datetime import datetime -from dateutil.parser import parse as parse_date -from typing import Optional, Dict, TypeVar, List, Iterable, Set, Union, Any - -from dataclasses import dataclass, field, asdict - -from .agent import Agent, agent_factory -from .annotation import Comment, Feature, Annotation, annotation_factory -from .compilation import Compilation -from .flag import Flag, flag_factory -from .meta import License, Classification -from .preview import Preview -from .process import ProcessStatus -from .proposal import Proposal -from .util import get_tzaware_utc_now, dict_coerce, list_coerce - - -@dataclass -class Author: - """Represents an author of a submission.""" - - order: int = field(default=0) - forename: str = field(default_factory=str) - surname: str = field(default_factory=str) - initials: str = field(default_factory=str) - affiliation: str = field(default_factory=str) - email: str = field(default_factory=str) - identifier: Optional[str] = field(default=None) - display: Optional[str] = field(default=None) - """ - Submitter may include a preferred display name for each author. - - If not provided, will be automatically generated from the other fields. - """ - - def __post_init__(self) -> None: - """Auto-generate an identifier, if not provided.""" - if not self.identifier: - self.identifier = self._generate_identifier() - if not self.display: - self.display = self.canonical - - def _generate_identifier(self) -> str: - h = hashlib.new('sha1') - h.update(bytes(':'.join([self.forename, self.surname, self.initials, - self.affiliation, self.email]), - encoding='utf-8')) - return h.hexdigest() - - @property - def canonical(self) -> str: - """Canonical representation of the author name.""" - name = "%s %s %s" % (self.forename, self.initials, self.surname) - name = name.replace(' ', ' ') - if self.affiliation: - return "%s (%s)" % (name, self.affiliation) - return name - - -@dataclass -class SubmissionContent: - """Metadata about the submission source package.""" - - class Format(Enum): - """Supported source formats.""" - - UNKNOWN = None - """We could not determine the source format.""" - INVALID = "invalid" - """We are able to infer the source format, and it is not supported.""" - TEX = "tex" - """A flavor of TeX.""" - PDFTEX = "pdftex" - """A PDF derived from TeX.""" - POSTSCRIPT = "ps" - """A postscript source.""" - HTML = "html" - """An HTML source.""" - PDF = "pdf" - """A PDF-only source.""" - - identifier: str - checksum: str - uncompressed_size: int - compressed_size: int - source_format: Format = Format.UNKNOWN - - def __post_init__(self) -> None: - """Make sure that :attr:`.source_format` is a :class:`.Format`.""" - if self.source_format and type(self.source_format) is str: - self.source_format = self.Format(self.source_format) - - -@dataclass -class SubmissionMetadata: - """Metadata about a :class:`.domain.submission.Submission` instance.""" - - title: Optional[str] = None - abstract: Optional[str] = None - - authors: list = field(default_factory=list) - authors_display: str = field(default_factory=str) - """The canonical arXiv author string.""" - - doi: Optional[str] = None - msc_class: Optional[str] = None - acm_class: Optional[str] = None - report_num: Optional[str] = None - journal_ref: Optional[str] = None - - comments: str = field(default_factory=str) - - -@dataclass -class Delegation: - """Delegation of editing privileges to a non-owning :class:`.Agent`.""" - - delegate: Agent - creator: Agent - created: datetime = field(default_factory=get_tzaware_utc_now) - delegation_id: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Set derivative fields.""" - self.delegation_id = self.get_delegation_id() - - def get_delegation_id(self) -> str: - """Generate unique identifier for the delegation instance.""" - h = hashlib.new('sha1') - h.update(b'%s:%s:%s' % (self.delegate.agent_identifier, - self.creator.agent_identifier, - self.created.isoformat())) - return h.hexdigest() - - -@dataclass -class Hold: - """Represents a block on announcement, usually for QA/QC purposes.""" - - class Type(Enum): - """Supported holds in the submission system.""" - - PATCH = 'patch' - """A hold generated from the classic submission system.""" - - SOURCE_OVERSIZE = "source_oversize" - """The submission source is oversize.""" - - PDF_OVERSIZE = "pdf_oversize" - """The submission PDF is oversize.""" - - event_id: str - """The event that created the hold.""" - - creator: Agent - created: datetime = field(default_factory=get_tzaware_utc_now) - hold_type: Type = field(default=Type.PATCH) - hold_reason: Optional[str] = field(default_factory=str) - - def __post_init__(self) -> None: - """Check enums and agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - self.hold_type = self.Type(self.hold_type) - # if not isinstance(created, datetime): - # created = parse_date(created) - - -@dataclass -class Waiver: - """Represents an exception or override.""" - - event_id: str - """The identifier of the event that produced this waiver.""" - waiver_type: Hold.Type - waiver_reason: str - created: datetime - creator: Agent - - def __post_init__(self) -> None: - """Check enums and agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - self.waiver_type = Hold.Type(self.waiver_type) - - -# TODO: add identification mechanism; consider using mechanism similar to -# comments, below. -@dataclass -class UserRequest: - """Represents a user request related to a submission.""" - - NAME = "User request base" - - WORKING = 'working' - """Request is not yet submitted.""" - - PENDING = 'pending' - """Request is pending approval.""" - - REJECTED = 'rejected' - """Request has been rejected.""" - - APPROVED = 'approved' - """Request has been approved.""" - - APPLIED = 'applied' - """Submission has been updated on the basis of the approved request.""" - - CANCELLED = 'cancelled' - - request_id: str - creator: Agent - created: datetime = field(default_factory=get_tzaware_utc_now) - updated: datetime = field(default_factory=get_tzaware_utc_now) - status: str = field(default=PENDING) - request_type: str = field(default_factory=str) - - def __post_init__(self) -> None: - """Check agents.""" - if self.creator and isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - self.request_type = self.get_request_type() - - def get_request_type(self) -> str: - """Name (str) of the type of user request.""" - return type(self).__name__ - - def is_pending(self) -> bool: - """Check whether the request is pending.""" - return self.status == UserRequest.PENDING - - def is_approved(self) -> bool: - """Check whether the request has been approved.""" - return self.status == UserRequest.APPROVED - - def is_applied(self) -> bool: - """Check whether the request has been applied.""" - return self.status == UserRequest.APPLIED - - def is_rejected(self) -> bool: - """Check whether the request has been rejected.""" - return self.status == UserRequest.REJECTED - - def is_active(self) -> bool: - """Check whether the request is active.""" - return self.is_pending() or self.is_approved() - - @classmethod - def generate_request_id(cls, submission: 'Submission', N: int = -1) -> str: - """Generate a unique identifier for this request.""" - h = hashlib.new('sha1') - if N < 0: - N = len([rq for rq in submission.iter_requests if type(rq) is cls]) - h.update(f'{submission.submission_id}:{cls.NAME}:{N}'.encode('utf-8')) - return h.hexdigest() - - def apply(self, submission: 'Submission') -> 'Submission': - """Stub for applying the proposal.""" - raise NotImplementedError('Must be implemented by child class') - - -@dataclass -class WithdrawalRequest(UserRequest): - """Represents a request to withdraw a submission.""" - - NAME = "Withdrawal" - - reason_for_withdrawal: Optional[str] = field(default=None) - """If an e-print is withdrawn, the submitter is asked to explain why.""" - - def apply(self, submission: 'Submission') -> 'Submission': - """Apply the withdrawal.""" - submission.reason_for_withdrawal = self.reason_for_withdrawal - submission.status = Submission.WITHDRAWN - return submission - - -@dataclass -class CrossListClassificationRequest(UserRequest): - """Represents a request to add secondary classifications.""" - - NAME = "Cross-list" - - classifications: List[Classification] = field(default_factory=list) - - def apply(self, submission: 'Submission') -> 'Submission': - """Apply the cross-list request.""" - submission.secondary_classification.extend(self.classifications) - return submission - - @property - def categories(self) -> List[str]: - """Get the requested cross-list categories.""" - return [c.category for c in self.classifications] - - -@dataclass -class Submission: - """ - Represents an arXiv submission object. - - Some notable differences between this view of submissions and the classic - model: - - - There is no "hold" status. Status reflects where the submission is - in the pipeline. Holds are annotations that can be applied to the - submission, and may impact its ability to proceed (e.g. from submitted - to scheduled). Submissions that are in working status can have holds on - them! - - We use `arxiv_id` instead of `paper_id` to refer to the canonical arXiv - identifier for the e-print (once it is announced). - - Instead of having a separate "submission" record for every change to an - e-print (e.g. replacement, jref, etc), we represent the entire history - as a single submission. Announced versions can be found in - :attr:`.versions`. Withdrawal and cross-list requests can be found in - :attr:`.user_requests`. JREFs are treated like they "just happen", - reflecting the forthcoming move away from storing journal ref information - in the core metadata record. - - """ - - WORKING = 'working' - SUBMITTED = 'submitted' - SCHEDULED = 'scheduled' - ANNOUNCED = 'announced' - ERROR = 'error' # TODO: eliminate this status. - DELETED = 'deleted' - WITHDRAWN = 'withdrawn' - - creator: Agent - owner: Agent - proxy: Optional[Agent] = field(default=None) - client: Optional[Agent] = field(default=None) - created: Optional[datetime] = field(default=None) - updated: Optional[datetime] = field(default=None) - submitted: Optional[datetime] = field(default=None) - submission_id: Optional[int] = field(default=None) - - source_content: Optional[SubmissionContent] = field(default=None) - preview: Optional[Preview] = field(default=None) - - metadata: SubmissionMetadata = field(default_factory=SubmissionMetadata) - primary_classification: Optional[Classification] = field(default=None) - secondary_classification: List[Classification] = \ - field(default_factory=list) - submitter_contact_verified: bool = field(default=False) - submitter_is_author: Optional[bool] = field(default=None) - submitter_accepts_policy: Optional[bool] = field(default=None) - is_source_processed: bool = field(default=False) - submitter_confirmed_preview: bool = field(default=False) - license: Optional[License] = field(default=None) - status: str = field(default=WORKING) - """Disposition within the submission pipeline.""" - - arxiv_id: Optional[str] = field(default=None) - """The announced arXiv paper ID.""" - - version: int = field(default=1) - - reason_for_withdrawal: Optional[str] = field(default=None) - """If an e-print is withdrawn, the submitter is asked to explain why.""" - - versions: List['Submission'] = field(default_factory=list) - """Announced versions of this :class:`.domain.submission.Submission`.""" - - # These fields are related to moderation/quality control. - user_requests: Dict[str, UserRequest] = field(default_factory=dict) - """Requests from the owner for changes that require approval.""" - - proposals: Dict[str, Proposal] = field(default_factory=dict) - """Proposed changes to the submission, e.g. reclassification.""" - - processes: List[ProcessStatus] = field(default_factory=list) - """Information about automated processes.""" - - annotations: Dict[str, Annotation] = field(default_factory=dict) - """Quality control annotations.""" - - flags: Dict[str, Flag] = field(default_factory=dict) - """Quality control flags.""" - - comments: Dict[str, Comment] = field(default_factory=dict) - """Moderation/administrative comments.""" - - holds: Dict[str, Hold] = field(default_factory=dict) - """Quality control holds.""" - - waivers: Dict[str, Waiver] = field(default_factory=dict) - """Quality control waivers.""" - - @property - def features(self) -> Dict[str, Feature]: - return {k: v for k, v in self.annotations.items() - if isinstance(v, Feature)} - - @property - def is_active(self) -> bool: - """Actively moving through the submission workflow.""" - return self.status not in [self.DELETED, self.ANNOUNCED] - - @property - def is_announced(self) -> bool: - """The submission has been announced.""" - if self.status == self.ANNOUNCED: - assert self.arxiv_id is not None - return True - return False - - @property - def is_finalized(self) -> bool: - """Submitter has indicated submission is ready for publication.""" - return self.status not in [self.WORKING, self.DELETED] - - @property - def is_deleted(self) -> bool: - """Submission is removed.""" - return self.status == self.DELETED - - @property - def primary_category(self) -> str: - """The primary classification category (as a string).""" - assert self.primary_classification is not None - return str(self.primary_classification.category) - - @property - def secondary_categories(self) -> List[str]: - """Category names from secondary classifications.""" - return [c.category for c in self.secondary_classification] - - @property - def is_on_hold(self) -> bool: - # We need to explicitly check ``status`` here because classic doesn't - # have a representation for Hold events. - return (self.status == self.SUBMITTED - and len(self.hold_types - self.waiver_types) > 0) - - def has_waiver_for(self, hold_type: Hold.Type) -> bool: - return hold_type in self.waiver_types - - @property - def hold_types(self) -> Set[Hold.Type]: - return set([hold.hold_type for hold in self.holds.values()]) - - @property - def waiver_types(self) -> Set[Hold.Type]: - return set([waiver.waiver_type for waiver in self.waivers.values()]) - - @property - def has_active_requests(self) -> bool: - return len(self.active_user_requests) > 0 - - @property - def iter_requests(self) -> Iterable[UserRequest]: - return self.user_requests.values() - - @property - def active_user_requests(self) -> List[UserRequest]: - return sorted(filter(lambda r: r.is_active(), self.iter_requests), - key=lambda r: r.created) - - @property - def pending_user_requests(self) -> List[UserRequest]: - return sorted(filter(lambda r: r.is_pending(), self.iter_requests), - key=lambda r: r.created) - - @property - def rejected_user_requests(self) -> List[UserRequest]: - return sorted(filter(lambda r: r.is_rejected(), self.iter_requests), - key=lambda r: r.created) - - @property - def approved_user_requests(self) -> List[UserRequest]: - return sorted(filter(lambda r: r.is_approved(), self.iter_requests), - key=lambda r: r.created) - - @property - def applied_user_requests(self) -> List[UserRequest]: - return sorted(filter(lambda r: r.is_applied(), self.iter_requests), - key=lambda r: r.created) - - def __post_init__(self) -> None: - if isinstance(self.creator, dict): - self.creator = agent_factory(**self.creator) - if isinstance(self.owner, dict): - self.owner = agent_factory(**self.owner) - if self.proxy and isinstance(self.proxy, dict): - self.proxy = agent_factory(**self.proxy) - if self.client and isinstance(self.client, dict): - self.client = agent_factory(**self.client) - if isinstance(self.created, str): - self.created = parse_date(self.created) - if isinstance(self.updated, str): - self.updated = parse_date(self.updated) - if isinstance(self.submitted, str): - self.submitted = parse_date(self.submitted) - if isinstance(self.source_content, dict): - self.source_content = SubmissionContent(**self.source_content) - if isinstance(self.preview, dict): - self.preview = Preview(**self.preview) - if isinstance(self.primary_classification, dict): - self.primary_classification = \ - Classification(**self.primary_classification) - if isinstance(self.metadata, dict): - self.metadata = SubmissionMetadata(**self.metadata) - # self.delegations = dict_coerce(Delegation, self.delegations) - self.secondary_classification = \ - list_coerce(Classification, self.secondary_classification) - if isinstance(self.license, dict): - self.license = License(**self.license) - self.versions = list_coerce(Submission, self.versions) - self.user_requests = dict_coerce(request_factory, self.user_requests) - self.proposals = dict_coerce(Proposal, self.proposals) - self.processes = list_coerce(ProcessStatus, self.processes) - self.annotations = dict_coerce(annotation_factory, self.annotations) - self.flags = dict_coerce(flag_factory, self.flags) - self.comments = dict_coerce(Comment, self.comments) - self.holds = dict_coerce(Hold, self.holds) - self.waivers = dict_coerce(Waiver, self.waivers) - - -def request_factory(**data: Any) -> UserRequest: - """Generate a :class:`.UserRequest` from raw data.""" - for cls in UserRequest.__subclasses__(): - if data['request_type'] == cls.__name__: - # Kind of defeats the purpose of this pattern if we have to type - # the params here. We can revisit the way this is implemented if - # it becomes an issue. - return cls(**data) # type: ignore - raise ValueError('Invalid request type') diff --git a/src/arxiv/submission/domain/tests/__init__.py b/src/arxiv/submission/domain/tests/__init__.py deleted file mode 100644 index 5aa0a13..0000000 --- a/src/arxiv/submission/domain/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for :mod:`arxiv.submission.domain`.""" diff --git a/src/arxiv/submission/domain/tests/test_events.py b/src/arxiv/submission/domain/tests/test_events.py deleted file mode 100644 index 8a41eb9..0000000 --- a/src/arxiv/submission/domain/tests/test_events.py +++ /dev/null @@ -1,1016 +0,0 @@ -"""Tests for :class:`.Event` instances in :mod:`arxiv.submission.domain.event`.""" - -from unittest import TestCase, mock -from datetime import datetime -from pytz import UTC -from mimesis import Text - -from arxiv import taxonomy -from ... import save -from .. import event, agent, submission, meta -from ...exceptions import InvalidEvent - - -class TestWithdrawalSubmission(TestCase): - """Test :class:`event.RequestWithdrawal`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User( - 12345, - 'uuser@cornell.edu', - endorsements=[meta.Classification('astro-ph.GA'), - meta.Classification('astro-ph.CO')] - ) - self.submission = submission.Submission( - submission_id=1, - status=submission.Submission.ANNOUNCED, - creator=self.user, - owner=self.user, - created=datetime.now(UTC), - source_content=submission.SubmissionContent( - identifier='6543', - source_format=submission.SubmissionContent.Format('pdf'), - checksum='asdf2345', - uncompressed_size=594930, - compressed_size=594930 - ), - primary_classification=meta.Classification('astro-ph.GA'), - secondary_classification=[meta.Classification('astro-ph.CO')], - license=meta.License(uri='http://free', name='free'), - arxiv_id='1901.001234', - version=1, - submitter_contact_verified=True, - submitter_is_author=True, - submitter_accepts_policy=True, - submitter_confirmed_preview=True, - metadata=submission.SubmissionMetadata( - title='the best title', - abstract='very abstract', - authors_display='J K Jones, F W Englund', - doi='10.1000/182', - comments='These are the comments' - ) - ) - - def test_request_withdrawal(self): - """Request that a paper be withdrawn.""" - e = event.RequestWithdrawal(creator=self.user, - created=datetime.now(UTC), - reason="no good") - e.validate(self.submission) - replacement = e.apply(self.submission) - self.assertEqual(replacement.arxiv_id, self.submission.arxiv_id) - self.assertEqual(replacement.version, self.submission.version) - self.assertEqual(replacement.status, - submission.Submission.ANNOUNCED) - self.assertTrue(replacement.has_active_requests) - self.assertTrue(self.submission.is_announced) - self.assertTrue(replacement.is_announced) - - def test_request_without_a_reason(self): - """A reason is required.""" - e = event.RequestWithdrawal(creator=self.user) - with self.assertRaises(event.InvalidEvent): - e.validate(self.submission) - - def test_request_without_announced_submission(self): - """The submission must already be announced.""" - e = event.RequestWithdrawal(creator=self.user, reason="no good") - with self.assertRaises(event.InvalidEvent): - e.validate(mock.MagicMock(announced=False)) - - -class TestReplacementSubmission(TestCase): - """Test :class:`event.CreateSubmission` with a replacement.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User( - 12345, - 'uuser@cornell.edu', - endorsements=[meta.Classification('astro-ph.GA'), - meta.Classification('astro-ph.CO')] - ) - self.submission = submission.Submission( - submission_id=1, - status=submission.Submission.ANNOUNCED, - creator=self.user, - owner=self.user, - created=datetime.now(UTC), - source_content=submission.SubmissionContent( - identifier='6543', - source_format=submission.SubmissionContent.Format('pdf'), - checksum='asdf2345', - uncompressed_size=594930, - compressed_size=594930 - ), - primary_classification=meta.Classification('astro-ph.GA'), - secondary_classification=[meta.Classification('astro-ph.CO')], - license=meta.License(uri='http://free', name='free'), - arxiv_id='1901.001234', - version=1, - submitter_contact_verified=True, - submitter_is_author=True, - submitter_accepts_policy=True, - submitter_confirmed_preview=True, - metadata=submission.SubmissionMetadata( - title='the best title', - abstract='very abstract', - authors_display='J K Jones, F W Englund', - doi='10.1000/182', - comments='These are the comments' - ) - ) - - def test_create_submission_replacement(self): - """A replacement is a new submission based on an old submission.""" - e = event.CreateSubmissionVersion(creator=self.user) - replacement = e.apply(self.submission) - self.assertEqual(replacement.arxiv_id, self.submission.arxiv_id) - self.assertEqual(replacement.version, self.submission.version + 1) - self.assertEqual(replacement.status, submission.Submission.WORKING) - self.assertTrue(self.submission.is_announced) - self.assertFalse(replacement.is_announced) - - self.assertIsNone(replacement.source_content) - - # The user is asked to reaffirm these points. - self.assertFalse(replacement.submitter_contact_verified) - self.assertFalse(replacement.submitter_accepts_policy) - self.assertFalse(replacement.submitter_confirmed_preview) - self.assertFalse(replacement.submitter_contact_verified) - - # These should all stay the same. - self.assertEqual(replacement.metadata.title, - self.submission.metadata.title) - self.assertEqual(replacement.metadata.abstract, - self.submission.metadata.abstract) - self.assertEqual(replacement.metadata.authors, - self.submission.metadata.authors) - self.assertEqual(replacement.metadata.authors_display, - self.submission.metadata.authors_display) - self.assertEqual(replacement.metadata.msc_class, - self.submission.metadata.msc_class) - self.assertEqual(replacement.metadata.acm_class, - self.submission.metadata.acm_class) - self.assertEqual(replacement.metadata.doi, - self.submission.metadata.doi) - self.assertEqual(replacement.metadata.journal_ref, - self.submission.metadata.journal_ref) - - -class TestDOIorJREFAfterAnnounce(TestCase): - """Test :class:`event.SetDOI` or :class:`event.SetJournalReference`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User( - 12345, - 'uuser@cornell.edu', - endorsements=[meta.Classification('astro-ph.GA'), - meta.Classification('astro-ph.CO')] - ) - self.submission = submission.Submission( - submission_id=1, - status=submission.Submission.ANNOUNCED, - creator=self.user, - owner=self.user, - created=datetime.now(UTC), - source_content=submission.SubmissionContent( - identifier='6543', - source_format=submission.SubmissionContent.Format('pdf'), - checksum='asdf2345', - uncompressed_size=594930, - compressed_size=594930 - ), - primary_classification=meta.Classification('astro-ph.GA'), - secondary_classification=[meta.Classification('astro-ph.CO')], - license=meta.License(uri='http://free', name='free'), - arxiv_id='1901.001234', - version=1, - submitter_contact_verified=True, - submitter_is_author=True, - submitter_accepts_policy=True, - submitter_confirmed_preview=True, - metadata=submission.SubmissionMetadata( - title='the best title', - abstract='very abstract', - authors_display='J K Jones, F W Englund', - doi='10.1000/182', - comments='These are the comments' - ) - ) - - def test_create_submission_jref(self): - """A JREF is just like a replacement, but different.""" - e = event.SetDOI(creator=self.user, doi='10.1000/182') - after = e.apply(self.submission) - self.assertEqual(after.arxiv_id, self.submission.arxiv_id) - self.assertEqual(after.version, self.submission.version) - self.assertEqual(after.status, submission.Submission.ANNOUNCED) - self.assertTrue(self.submission.is_announced) - self.assertTrue(after.is_announced) - - self.assertIsNotNone(after.submission_id) - self.assertEqual(self.submission.submission_id, after.submission_id) - - # The user is NOT asked to reaffirm these points. - self.assertTrue(after.submitter_contact_verified) - self.assertTrue(after.submitter_accepts_policy) - self.assertTrue(after.submitter_confirmed_preview) - self.assertTrue(after.submitter_contact_verified) - - # These should all stay the same. - self.assertEqual(after.metadata.title, - self.submission.metadata.title) - self.assertEqual(after.metadata.abstract, - self.submission.metadata.abstract) - self.assertEqual(after.metadata.authors, - self.submission.metadata.authors) - self.assertEqual(after.metadata.authors_display, - self.submission.metadata.authors_display) - self.assertEqual(after.metadata.msc_class, - self.submission.metadata.msc_class) - self.assertEqual(after.metadata.acm_class, - self.submission.metadata.acm_class) - self.assertEqual(after.metadata.doi, - self.submission.metadata.doi) - self.assertEqual(after.metadata.journal_ref, - self.submission.metadata.journal_ref) - - - -class TestSetPrimaryClassification(TestCase): - """Test :class:`event.SetPrimaryClassification`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User( - 12345, - 'uuser@cornell.edu', - endorsements=[meta.Classification('astro-ph.GA'), - meta.Classification('astro-ph.CO')] - ) - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_set_primary_with_nonsense(self): - """Category is not from the arXiv taxonomy.""" - e = event.SetPrimaryClassification( - creator=self.user, - submission_id=1, - category="nonsense" - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_set_primary_inactive(self): - """Category is not from the arXiv taxonomy.""" - e = event.SetPrimaryClassification( - creator=self.user, - submission_id=1, - category="chao-dyn" - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_set_primary_with_valid_category(self): - """Category is from the arXiv taxonomy.""" - for category in taxonomy.CATEGORIES.keys(): - e = event.SetPrimaryClassification( - creator=self.user, - submission_id=1, - category=category - ) - if category in self.user.endorsements: - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail("Event should be valid") - else: - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_set_primary_already_secondary(self): - """Category is already set as a secondary.""" - classification = submission.Classification('cond-mat.dis-nn') - self.submission.secondary_classification.append(classification) - e = event.SetPrimaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.dis-nn' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - -class TestAddSecondaryClassification(TestCase): - """Test :class:`event.AddSecondaryClassification`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC), - secondary_classification=[] - ) - - def test_add_secondary_with_nonsense(self): - """Category is not from the arXiv taxonomy.""" - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category="nonsense" - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_add_secondary_inactive(self): - """Category is inactive.""" - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category="bayes-an" - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_add_secondary_with_valid_category(self): - """Category is from the arXiv taxonomy.""" - for category in taxonomy.CATEGORIES_ACTIVE.keys(): - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category=category - ) - try: - e.validate(self.submission) - except InvalidEvent: - if category != 'physics.gen-ph': - self.fail("Event should be valid") - - def test_add_secondary_already_present(self): - """Category is already present on the submission.""" - self.submission.secondary_classification.append( - submission.Classification('cond-mat.dis-nn') - ) - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.dis-nn' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_add_secondary_already_primary(self): - """Category is already set as primary.""" - classification = submission.Classification('cond-mat.dis-nn') - self.submission.primary_classification = classification - - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.dis-nn' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_add_general_secondary(self): - """Category is more general than the existing categories.""" - classification = submission.Classification('physics.optics') - self.submission.primary_classification = classification - - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='physics.gen-ph' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - classification = submission.Classification('cond-mat.quant-gas') - self.submission.primary_classification = classification - - self.submission.secondary_classification.append( - submission.Classification('physics.optics')) - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='physics.gen-ph' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_add_specific_secondary(self): - """Category is more specific than existing general category.""" - classification = submission.Classification('physics.gen-ph') - self.submission.primary_classification = classification - - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='physics.optics' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - classification = submission.Classification('astro-ph.SR') - self.submission.primary_classification = classification - - self.submission.secondary_classification.append( - submission.Classification('physics.gen-ph')) - e = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='physics.optics' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_add_max_secondaries(self): - """Test max secondaries.""" - self.submission.secondary_classification.append( - submission.Classification('cond-mat.dis-nn')) - self.submission.secondary_classification.append( - submission.Classification('cond-mat.mes-hall')) - self.submission.secondary_classification.append( - submission.Classification('cond-mat.mtrl-sci')) - - e1 = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.quant-gas' - ) - e1.validate(self.submission) - self.submission.secondary_classification.append( - submission.Classification('cond-mat.quant-gas')) - - e2 = event.AddSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.str-el' - ) - - self.assertEqual(len(self.submission.secondary_classification), 4) - with self.assertRaises(InvalidEvent): - e2.validate(self.submission) # "Event should not be valid". - - -class TestRemoveSecondaryClassification(TestCase): - """Test :class:`event.RemoveSecondaryClassification`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC), - secondary_classification=[] - ) - - def test_add_secondary_with_nonsense(self): - """Category is not from the arXiv taxonomy.""" - e = event.RemoveSecondaryClassification( - creator=self.user, - submission_id=1, - category="nonsense" - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - def test_remove_secondary_with_valid_category(self): - """Category is from the arXiv taxonomy.""" - classification = submission.Classification('cond-mat.dis-nn') - self.submission.secondary_classification.append(classification) - e = event.RemoveSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.dis-nn' - ) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail("Event should be valid") - - def test_remove_secondary_not_present(self): - """Category is not present.""" - e = event.RemoveSecondaryClassification( - creator=self.user, - submission_id=1, - category='cond-mat.dis-nn' - ) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) # "Event should not be valid". - - -class TestSetAuthors(TestCase): - """Test :class:`event.SetAuthors`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_canonical_authors_provided(self): - """Data includes canonical author display string.""" - e = event.SetAuthors(creator=self.user, - submission_id=1, - authors=[submission.Author()], - authors_display="Foo authors") - try: - e.validate(self.submission) - except Exception as e: - self.fail(str(e), "Data should be valid") - s = e.project(self.submission) - self.assertEqual(s.metadata.authors_display, e.authors_display, - "Authors string should be updated") - - def test_canonical_authors_not_provided(self): - """Data does not include canonical author display string.""" - e = event.SetAuthors( - creator=self.user, - submission_id=1, - authors=[ - submission.Author( - forename="Bob", - surname="Paulson", - affiliation="FSU" - ) - ]) - self.assertEqual(e.authors_display, "Bob Paulson (FSU)", - "Display string should be generated automagically") - - try: - e.validate(self.submission) - except Exception as e: - self.fail(str(e), "Data should be valid") - s = e.project(self.submission) - self.assertEqual(s.metadata.authors_display, e.authors_display, - "Authors string should be updated") - - def test_canonical_authors_contains_et_al(self): - """Author display value contains et al.""" - e = event.SetAuthors(creator=self.user, - submission_id=1, - authors=[submission.Author()], - authors_display="Foo authors, et al") - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - -class TestSetTitle(TestCase): - """Tests for :class:`.event.SetTitle`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_empty_value(self): - """Title is set to an empty string.""" - e = event.SetTitle(creator=self.user, title='') - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_reasonable_title(self): - """Title is set to some reasonable value smaller than 240 chars.""" - for _ in range(100): # Add a little fuzz to the mix. - for locale in LOCALES: - title = Text(locale=locale).text(6)[:240] \ - .strip() \ - .rstrip('.') \ - .replace('@', '') \ - .replace('#', '') \ - .title() - e = event.SetTitle(creator=self.user, title=title) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle title: %s' % title) - - def test_all_caps_title(self): - """Title is all uppercase.""" - title = Text().title()[:240].upper() - e = event.SetTitle(creator=self.user, title=title) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_title_ends_with_period(self): - """Title ends with a period.""" - title = Text().title()[:239] + "." - e = event.SetTitle(creator=self.user, title=title) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_title_ends_with_ellipsis(self): - """Title ends with an ellipsis.""" - title = Text().title()[:236] + "..." - e = event.SetTitle(creator=self.user, title=title) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail("Should accept ellipsis") - - def test_huge_title(self): - """Title is set to something unreasonably large.""" - title = Text().text(200) # 200 sentences. - e = event.SetTitle(creator=self.user, title=title) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_title_with_html_escapes(self): - """Title should not allow HTML escapes.""" - e = event.SetTitle(creator=self.user, title='foo   title') - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - -class TestSetAbstract(TestCase): - """Tests for :class:`.event.SetAbstract`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_empty_value(self): - """Abstract is set to an empty string.""" - e = event.SetAbstract(creator=self.user, abstract='') - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - def test_reasonable_abstract(self): - """Abstract is set to some reasonable value smaller than 1920 chars.""" - for locale in LOCALES: - abstract = Text(locale=locale).text(20)[:1920] - e = event.SetAbstract(creator=self.user, abstract=abstract) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle abstract: %s' % abstract) - - def test_huge_abstract(self): - """Abstract is set to something unreasonably large.""" - abstract = Text().text(200) # 200 sentences. - e = event.SetAbstract(creator=self.user, abstract=abstract) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - -class TestSetDOI(TestCase): - """Tests for :class:`.event.SetDOI`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_empty_doi(self): - """DOI is set to an empty string.""" - doi = "" - e = event.SetDOI(creator=self.user, doi=doi) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle valid DOI: %s' % e) - - def test_valid_doi(self): - """DOI is set to a single valid DOI.""" - doi = "10.1016/S0550-3213(01)00405-9" - e = event.SetDOI(creator=self.user, doi=doi) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle valid DOI: %s' % e) - - def test_multiple_valid_dois(self): - """DOI is set to multiple valid DOIs.""" - doi = "10.1016/S0550-3213(01)00405-9, 10.1016/S0550-3213(01)00405-8" - e = event.SetDOI(creator=self.user, doi=doi) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle valid DOI: %s' % e) - - def test_invalid_doi(self): - """DOI is set to something other than a valid DOI.""" - not_a_doi = "101016S0550-3213(01)00405-9" - e = event.SetDOI(creator=self.user, doi=not_a_doi) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - -class TestSetReportNumber(TestCase): - """Tests for :class:`.event.SetReportNumber`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_valid_report_number(self): - """Valid report number values are used.""" - values = [ - "IPhT-T10/027", - "SITP 10/04, OIQP-10-01", - "UK/09-07", - "COLO-HEP-550, UCI-TR-2009-12", - "TKYNT-10-01, UTHEP-605", - "1003.1130", - "CDMTCS-379", - "BU-HEPP-09-06", - "IMSC-PHYSICS/08-2009, CU-PHYSICS/2-2010", - "CRM preprint No. 867", - "SLAC-PUB-13848, AEI-2009-110, ITP-UH-18/09", - "SLAC-PUB-14011", - "KUNS-2257, DCPT-10/11", - "TTP09-41, SFB/CPP-09-110, Alberta Thy 16-09", - "DPUR/TH/20", - "KEK Preprint 2009-41, Belle Preprint 2010-02, NTLP Preprint 2010-01", - "CERN-PH-EP/2009-018", - "Computer Science ISSN 19475500", - "Computer Science ISSN 19475500", - "Computer Science ISSN 19475500", - "" - ] - for value in values: - try: - e = event.SetReportNumber(creator=self.user, report_num=value) - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle %s: %s' % (value, e)) - - def test_invalid_values(self): - """Some invalid values are passed.""" - values = [ - "not a report number", - ] - for value in values: - with self.assertRaises(InvalidEvent): - e = event.SetReportNumber(creator=self.user, report_num=value) - e.validate(self.submission) - - -class TestSetJournalReference(TestCase): - """Tests for :class:`.event.SetJournalReference`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_valid_journal_ref(self): - """Valid journal ref values are used.""" - values = [ - "Phys. Rev. Lett. 104, 097003 (2010)", - "Phys. Rev. B v81, 094405 (2010)", - "Phys. Rev. D81 (2010) 036004", - "Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 74, 033822 (2006)Phys. Rev. A 81, 032303 (2010)", - "Opt. Lett. 35, 499-501 (2010)", - "Phys. Rev. D 81, 034023 (2010)", - "Opt. Lett. Vol.31 (2010)", - "Fundamental and Applied Mathematics, 14(8)(2008), 55-67. (in Russian)", - "Czech J Math, 60(135)(2010), 59-76.", - "PHYSICAL REVIEW B 81, 024520 (2010)", - "PHYSICAL REVIEW B 69, 094524 (2004)", - "Announced on Ap&SS, Oct. 2009", - "Phys. Rev. Lett. 104, 095701 (2010)", - "Phys. Rev. B 76, 205407 (2007).", - "Extending Database Technology (EDBT) 2010", - "Database and Expert Systems Applications (DEXA) 2009", - "J. Math. Phys. 51 (2010), no. 3, 033503, 12pp", - "South East Asian Bulletin of Mathematics, Vol. 33 (2009), 853-864.", - "Acta Mathematica Academiae Paedagogiace Nyíregyháziensis, Vol. 25, No. 2 (2009), 189-190.", - "Creative Mathematics and Informatics, Vol. 18, No. 1 (2009), 39-45.", - "" - ] - for value in values: - try: - e = event.SetJournalReference(creator=self.user, - journal_ref=value) - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle %s: %s' % (value, e)) - - def test_invalid_values(self): - """Some invalid values are passed.""" - values = [ - "Phys. Rev. Lett. 104, 097003 ()", - "Phys. Rev. accept submit B v81, 094405 (2010)", - "Phys. Rev. D81 036004", - ] - for value in values: - with self.assertRaises(InvalidEvent): - e = event.SetJournalReference(creator=self.user, - journal_ref=value) - e.validate(self.submission) - - -class TestSetACMClassification(TestCase): - """Tests for :class:`.event.SetACMClassification`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_valid_acm_class(self): - """ACM classification value is valid.""" - values = [ - "H.2.4", - "F.2.2; H.3.m", - "H.2.8", - "H.2.4", - "G.2.1", - "D.1.1", - "G.2.2", - "C.4", - "I.2.4", - "I.6.3", - "D.2.8", - "B.7.2", - "D.2.4; D.3.1; D.3.2; F.3.2", - "F.2.2; I.2.7", - "G.2.2", - "D.3.1; F.3.2", - "F.4.1; F.4.2", - "C.2.1; G.2.2", - "F.2.2; G.2.2; G.3; I.6.1; J.3 ", - "H.2.8; K.4.4; H.3.5", - "" - ] - for value in values: - try: - e = event.SetACMClassification(creator=self.user, - acm_class=value) - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle %s: %s' % (value, e)) - - -class TestSetMSCClassification(TestCase): - """Tests for :class:`.event.SetMSCClassification`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_valid_msc_class(self): - """MSC classification value is valid.""" - values = [ - "57M25", - "35k55; 35k65", - "60G51", - "16S15, 13P10, 17A32, 17A99", - "16S15, 13P10, 17A30", - "05A15 ; 30F10 ; 30D05", - "16S15, 13P10, 17A01, 17B67, 16D10", - "primary 05A15 ; secondary 30F10, 30D05.", - "35B45 (Primary), 35J40 (Secondary)", - "13D45, 13C14, 13Exx", - "13D45, 13C14", - "57M25; 05C50", - "32G34 (Primary), 14D07 (Secondary)", - "05C75, 60G09", - "14H20; 13A18; 13F30", - "49K10; 26A33; 26B20", - "20NO5, 08A05", - "20NO5 (Primary), 08A05 (Secondary)", - "83D05", - "20NO5; 08A05" - ] - for value in values: - try: - e = event.SetMSCClassification(creator=self.user, - msc_class=value) - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle %s: %s' % (value, e)) - - -class TestSetComments(TestCase): - """Tests for :class:`.event.SetComments`.""" - - def setUp(self): - """Initialize auxiliary data for test cases.""" - self.user = agent.User(12345, 'uuser@cornell.edu') - self.submission = submission.Submission( - submission_id=1, - creator=self.user, - owner=self.user, - created=datetime.now(UTC) - ) - - def test_empty_value(self): - """Comment is set to an empty string.""" - e = event.SetComments(creator=self.user, comments='') - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle empty comments') - - def test_reasonable_comment(self): - """Comment is set to some reasonable value smaller than 400 chars.""" - for locale in LOCALES: - comments = Text(locale=locale).text(20)[:400] - e = event.SetComments(creator=self.user, comments=comments) - try: - e.validate(self.submission) - except InvalidEvent as e: - self.fail('Failed to handle comments: %s' % comments) - - def test_huge_comment(self): - """Comment is set to something unreasonably large.""" - comments = Text().text(200) # 200 sentences. - e = event.SetComments(creator=self.user, comments=comments) - with self.assertRaises(InvalidEvent): - e.validate(self.submission) - - -# Locales supported by mimesis. -LOCALES = [ - "cs", - "da", - "de", - "de-at", - "de-ch", - "el", - "en", - "en-au", - "en-ca", - "en-gb", - "es", - "es-mx", - "et", - "fa", - "fi", - "fr", - "hu", - "is", - "it", - "ja", - "kk", - "ko", - "nl", - "nl-be", - "no", - "pl", - "pt", - "pt-br", - "ru", - "sv", - "tr", - "uk", - "zh", -] diff --git a/src/arxiv/submission/domain/uploads.py b/src/arxiv/submission/domain/uploads.py deleted file mode 100644 index b81ce7e..0000000 --- a/src/arxiv/submission/domain/uploads.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Upload-related data structures.""" - -from typing import NamedTuple, List, Optional, Dict, MutableMapping, Iterable -import io -from datetime import datetime -import dateutil.parser -from enum import Enum -import io - -from .submission import Submission, SubmissionContent - - -class FileErrorLevels(Enum): - """Error severities.""" - - ERROR = 'ERROR' - WARNING = 'WARN' - - -class FileError(NamedTuple): - """Represents an error returned by the file management service.""" - - error_type: FileErrorLevels - message: str - more_info: Optional[str] = None - - def to_dict(self) -> dict: - """Generate a dict representation of this error.""" - return { - 'error_type': self.error_type, - 'message': self.message, - 'more_info': self.more_info - } - - @classmethod - def from_dict(cls: type, data: dict) -> 'FileError': - """Instantiate a :class:`FileError` from a dict.""" - instance: FileError = cls(**data) - return instance - - -class FileStatus(NamedTuple): - """Represents the state of an uploaded file.""" - - path: str - name: str - file_type: str - size: int - modified: datetime - ancillary: bool = False - errors: List[FileError] = [] - - def to_dict(self) -> dict: - """Generate a dict representation of this status object.""" - data = { - 'path': self.path, - 'name': self.name, - 'file_type': self.file_type, - 'size': self.size, - 'modified': self.modified.isoformat(), - 'ancillary': self.ancillary, - 'errors': [e.to_dict() for e in self.errors] - } - # if data['modified']: - # data['modified'] = data['modified'] - # if data['errors']: - # data['errors'] = [e.to_dict() for e in data['errors']] - return data - - @classmethod - def from_dict(cls: type, data: dict) -> 'Upload': - """Instantiate a :class:`FileStatus` from a dict.""" - if 'errors' in data: - data['errors'] = [FileError.from_dict(e) for e in data['errors']] - if 'modified' in data and type(data['modified']) is str: - data['modified'] = dateutil.parser.parse(data['modified']) - instance: Upload = cls(**data) - return instance - - -class UploadStatus(Enum): # type: ignore - """The status of the upload workspace with respect to submission.""" - - READY = 'READY' - READY_WITH_WARNINGS = 'READY_WITH_WARNINGS' - ERRORS = 'ERRORS' - -class UploadLifecycleStates(Enum): # type: ignore - """The status of the workspace with respect to its lifecycle.""" - - ACTIVE = 'ACTIVE' - RELEASED = 'RELEASED' - DELETED = 'DELETED' - - -class Upload(NamedTuple): - """Represents the state of an upload workspace.""" - - started: datetime - completed: datetime - created: datetime - modified: datetime - status: UploadStatus - lifecycle: UploadLifecycleStates - locked: bool - identifier: int - source_format: SubmissionContent.Format = SubmissionContent.Format.UNKNOWN - checksum: Optional[str] = None - size: Optional[int] = None - """Size in bytes of the uncompressed upload workspace.""" - compressed_size: Optional[int] = None - """Size in bytes of the compressed upload package.""" - files: List[FileStatus] = [] - errors: List[FileError] = [] - - @property - def file_count(self) -> int: - """The number of files in the workspace.""" - return len(self.files) - - def to_dict(self) -> dict: - """Generate a dict representation of this status object.""" - return { - 'started': self.started.isoformat(), - 'completed': self.completed.isoformat(), - 'created': self.created.isoformat(), - 'modified': self.modified.isoformat(), - 'status': self.status.value, - 'lifecycle': self.lifecycle.value, - 'locked': self.locked, - 'identifier': self.identifier, - 'source_format': self.source_format.value, - 'checksum': self.checksum, - 'size': self.size, - 'files': [d.to_dict() for d in self.files], - 'errors': [d.to_dict() for d in self.errors] - } - - @classmethod - def from_dict(cls: type, data: dict) -> 'Upload': - """Instantiate an :class:`Upload` from a dict.""" - if 'files' in data: - data['files'] = [FileStatus.from_dict(f) for f in data['files']] - if 'errors' in data: - data['errors'] = [FileError.from_dict(e) for e in data['errors']] - for key in ['started', 'completed', 'created', 'modified']: - if key in data and type(data[key]) is str: - data[key] = dateutil.parser.parse(data[key]) - if 'source_format' in data: - data['source_format'] = \ - SubmissionContent.Format(data['source_format']) - instance: Upload = cls(**data) - return instance diff --git a/src/arxiv/submission/domain/util.py b/src/arxiv/submission/domain/util.py deleted file mode 100644 index 74f4358..0000000 --- a/src/arxiv/submission/domain/util.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Helpers and utilities.""" - -from typing import Dict, Any, List, Optional, Callable, Iterable -from datetime import datetime -from pytz import UTC - - -def get_tzaware_utc_now() -> datetime: - """Generate a datetime for the current moment in UTC.""" - return datetime.now(UTC) - - -def dict_coerce(factory: Callable[..., Any], data: dict) -> Dict[str, Any]: - return {event_id: factory(**value) if isinstance(value, dict) else value - for event_id, value in data.items()} - - -def list_coerce(factory: type, data: Iterable) -> List[Any]: - return [factory(**value) for value in data if isinstance(value, dict)] diff --git a/src/arxiv/submission/exceptions.py b/src/arxiv/submission/exceptions.py deleted file mode 100644 index 7ecbb55..0000000 --- a/src/arxiv/submission/exceptions.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Exceptions raised during event handling.""" - -from typing import TypeVar, List - -EventType = TypeVar('EventType') - - -class InvalidEvent(ValueError): - """Raised when an invalid event is encountered.""" - - def __init__(self, event: EventType, message: str = '') -> None: - """Use the :class:`.Event` to build an error message.""" - self.event = event - self.message = message - r = f"Invalid {event.event_type}: {message}" # type: ignore - super(InvalidEvent, self).__init__(r) - - -class NoSuchSubmission(Exception): - """An operation was performed on/for a submission that does not exist.""" - - -class SaveError(RuntimeError): - """Failed to persist event state.""" - - -class NothingToDo(RuntimeError): - """There is nothing to do.""" diff --git a/src/arxiv/submission/process/__init__.py b/src/arxiv/submission/process/__init__.py deleted file mode 100644 index 04914c2..0000000 --- a/src/arxiv/submission/process/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Core submission processes.""" - diff --git a/src/arxiv/submission/process/process_source.py b/src/arxiv/submission/process/process_source.py deleted file mode 100644 index 146e01b..0000000 --- a/src/arxiv/submission/process/process_source.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Core procedures for processing source content. - -In order for a submission to be finalized, it must have a valid source package, -and the source must be processed. Source processing involves the transformation -(and possibly validation) of sanitized source content (generally housed in the -file manager service) into a usable preview (generally a PDF) that is housed in -the submission preview service. - -The specific steps involved in source processing vary among supported source -formats. The primary objective of this module is to encapsulate in one location -the orchestration involved in processing submission source packages. - -The end result of source processing is the generation of a -:class:`.ConfirmSourceProcessed` event. This event signifies that the source -has been processed succesfully, and that a corresponding preview may be found -in the preview service. - -Implementing support for a new format -===================================== -Processing support for a new format can be implemented by registering a new -:class:`SourceProcess`, using :func:`._make_process`. Each source process -supports a specific :class:`SubmissionContent.Format`, and should provide a -starter, a checker, and a summarizer. The preferred approach is to extend the -base classes, :class:`.BaseStarter` and :class:`.BaseChecker`. - -Using a process -=============== -The primary API of this module is comprised of the functions :func:`start` and -:func:`check`. These functions dispatch to the processes defined/registered in -this module. - -""" -import io -from typing import IO, Dict, Tuple, NamedTuple, Optional, Any, Callable, Type, Protocol - -from mypy_extensions import TypedDict - -# Mypy has a hard time with namespace packages. See -# https://github.com/python/mypy/issues/5759 -from arxiv.base import logging # type: ignore -from arxiv.integration.api.exceptions import NotFound # type: ignore -from .. import save, User, Client -from ..domain import Preview, SubmissionContent, Submission, Compilation -from ..domain.event import ConfirmSourceProcessed, UnConfirmSourceProcessed -from ..services import PreviewService, Compiler, Filemanager - -logger = logging.getLogger(__name__) - -Status = str -SUCCEEDED: Status = 'succeeded' -FAILED: Status = 'failed' -IN_PROGRESS: Status = 'in_progress' -NOT_STARTED: Status = 'not_started' - -Summary = Dict[str, Any] -"""Summary information suitable for generating a response to users/clients.""" - -class IProcess(Protocol): - """Interface for processing classes.""" - - def __init__(self, submission: Submission, user: User, - client: Optional[Client], token: str) -> None: - """Initialize the process with a submission and agent context.""" - ... - - def __call__(self) -> 'CheckResult': - """Perform the process step.""" - ... - - -class SourceProcess(NamedTuple): - """Container for source processing routines for a specific format.""" - - supports: SubmissionContent.Format - """The source format supported by this process.""" - - start: Type[IProcess] - """A function for starting processing.""" - - check: Type[IProcess] - """A function for checking the status of processing.""" - - -class CheckResult(NamedTuple): - """Information about the result of a check.""" - - status: Status - """The status of source processing.""" - - extra: Dict[str, Any] - """ - Additional data, which may vary by source type and status. - - Summary information suitable for generating feedback to an end user or API - consumer. E.g. to be injected in a template rendering context. - """ - - -_PROCESSES: Dict[SubmissionContent.Format, SourceProcess] = {} - - -# These exceptions refer to errors encountered during checking, and not to the -# status of source processing itself. -class SourceProcessingException(RuntimeError): - """Base exception for this module.""" - - -class FailedToCheckStatus(SourceProcessingException): - """Could not check the status of processing.""" - - -class NoProcessToCheck(SourceProcessingException): - """Attempted to check a process that does not exist.""" - - -class FailedToStart(SourceProcessingException): - """Could not start processing.""" - - -class FailedToGetResult(SourceProcessingException): - """Could not get the result of processing.""" - - -class _ProcessBase: - """Base class for processing steps.""" - - submission: Submission - user: User - client: Optional[Client] - token: str - extra: Dict[str, Any] - status: Optional[Status] - preview: Optional[Preview] - - def __init__(self, submission: Submission, user: User, - client: Optional[Client], token: str) -> None: - """Initialize with a submission.""" - self.submission = submission - self.user = user - self.client = client - self.token = token - self.extra = {} - self.status = None - self.preview = None - - def _deposit(self, stream: IO[bytes], content_checksum: str) -> None: - """Deposit the preview, and set :attr:`.preview`.""" - assert self.submission.source_content is not None - # It is possible that the content is already there, we just failed to - # update the submission last time. In the future we might do a more - # efficient check, but this is fine for now. - p = PreviewService.current_session() - self.preview = p.deposit(self.submission.source_content.identifier, - self.submission.source_content.checksum, - stream, self.token, overwrite=True, - content_checksum=content_checksum) - - def _confirm_processed(self) -> None: - if self.preview is None: - raise RuntimeError('Cannot confirm processing without a preview') - event = ConfirmSourceProcessed( # type: ignore - creator=self.user, - client=self.client, - source_id=self.preview.source_id, - source_checksum=self.preview.source_checksum, - preview_checksum=self.preview.preview_checksum, - size_bytes=self.preview.size_bytes, - added=self.preview.added - ) - self.submission, _ = save(event, - submission_id=self.submission.submission_id) - - def _unconfirm_processed(self) -> None: - assert self.submission.submission_id is not None - if not self.submission.is_source_processed: - return - event = UnConfirmSourceProcessed(creator=self.user, client=self.client) # type: ignore - self.submission, _ = save(event, - submission_id=self.submission.submission_id) - - def finish(self, stream: IO[bytes], content_checksum: str) -> None: - """ - Wraps up by depositing the preview and updating the submission. - - This should be called by a terminal processing implementation, as the - appropriate moment to do this may vary among workflows. - """ - self._deposit(stream, content_checksum) - self._confirm_processed() - - -class BaseStarter(_ProcessBase): - """ - Base class for starting processing. - - To extend this class, override :func:`BaseStarter.start`. That function - should perform whatever steps are necessary to start processing, and - return a :const:`.Status` that indicates the disposition of - processing for that submission. - """ - - def start(self) -> Tuple[Status, Dict[str, Any]]: - """Start processing the source. Must be implemented by child class.""" - raise NotImplementedError('Must be implemented by a child class') - - def __call__(self) -> CheckResult: - """Start processing a submission source package.""" - try: - self._unconfirm_processed() - self.status, extra = self.start() - self.extra.update(extra) - except SourceProcessingException: # Propagate. - raise - # except Exception as e: - # message = f'Could not start: {self.submission.submission_id}' - # logger.error('Caught unexpected exception: %s', e) - # raise FailedToStart(message) from e - return CheckResult(status=self.status, extra=self.extra) - - -class BaseChecker(_ProcessBase): - """ - Base class for checking the status of processing. - - To extend this class, override :func:`BaseStarter.check`. That function - should return a :const:`.Status` that indicates the disposition of - processing for a given submission. - """ - - def check(self) -> Tuple[Status, Dict[str, Any]]: - """Perform the status check.""" - raise NotImplementedError('Must be implemented by a subclass') - - def _pre_check(self) -> None: - assert self.submission.source_content is not None - if self.submission.is_source_processed \ - and self.submission.preview is not None: - p = PreviewService.current_session() - is_ok = p.has_preview(self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token, - self.submission.preview.preview_checksum) - if is_ok: - self.extra.update({'preview': self.submission.preview}) - self.status = SUCCEEDED - - def __call__(self) -> CheckResult: - """Check the status of source processing for a submission.""" - try: - self._pre_check() - self.status, extra = self.check() - self.extra.update(extra) - except SourceProcessingException: # Propagate. - raise - except Exception as e: - raise FailedToCheckStatus(f'Status check failed: {e}') from e - return CheckResult(status=self.status, extra=self.extra) - - -class _PDFStarter(BaseStarter): - """Start processing a PDF source package.""" - - def start(self) -> Tuple[Status, Dict[str, Any]]: - """Retrieve the PDF from the file manager service and finish.""" - if self.submission.source_content is None: - return FAILED, {'reason': 'Submission has no source package'} - m = Filemanager.current_session() - try: - stream, checksum, content_checksum = \ - m.get_single_file(self.submission.source_content.identifier, - self.token) - except NotFound: - return FAILED, {'reason': 'Does not have a single PDF file.'} - if self.submission.source_content.checksum != checksum: - logger.error('source checksum and retrieved checksum do not match;' - f' expected {self.submission.source_content.checksum}' - f' but got {checksum}') - return FAILED, {'reason': 'Source has changed.'} - - self.finish(stream, content_checksum) - return SUCCEEDED, {} - - -class _PDFChecker(BaseChecker): - """Check the status of a PDF source package.""" - - def check(self) -> Tuple[Status, Dict[str, Any]]: - """Verify that the preview is present.""" - if self.submission.source_content is None: - return FAILED, {'reason': 'Submission has no source package'} - if self.status is not None: - return self.status, {} - p = PreviewService.current_session() - try: - preview = p.get_metadata( - self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token - ) - except NotFound: - return NOT_STARTED, {} - if self.submission.source_content.checksum != preview.source_checksum: - return NOT_STARTED, {'reason': 'Source has changed.'} - self.preview = preview - return SUCCEEDED, {} - - -class _CompilationStarter(BaseStarter): - """Starts compilation via the compiler service.""" - - def start(self) -> Tuple[Status, Dict[str, Any]]: - """Start compilation.""" - if self.submission.source_content is None: - return FAILED, {'reason': 'Submission has no source package'} - c = Compiler.current_session() - stat = c.compile(self.submission.source_content.identifier, - self.submission.source_content.checksum, self.token, - *self._make_stamp(), force=True) - - # There is no good reason for this to come back as failed right off - # the bat, so we will treat it as a bona fide exception rather than - # just FAILED state. - if stat.is_failed: - raise FailedToStart(f'Failed to start: {stat.Reason.value}') - - # If we got this far, we're off to the races. - return IN_PROGRESS, {} - - def _make_stamp(self) -> Tuple[str, str]: - """ - Create label and link for PS/PDF stamp/watermark. - - Stamp format for submission is of form ``[identifier category date]`` - - ``arXiv:submit/ [] DD MON YYYY`` - - Date segment is optional and added automatically by converter. - """ - stamp_label = f'arXiv:submit/{self.submission.submission_id}' - - if self.submission.primary_classification \ - and self.submission.primary_classification.category: - # Create stamp label string - for now we'll let converter - # add date segment to stamp label - primary_category = self.submission.primary_classification.category - stamp_label = f'{stamp_label} [{primary_category}]' - - stamp_link = f'/{self.submission.submission_id}/preview.pdf' - return stamp_label, stamp_link - - -class _CompilationChecker(BaseChecker): - def check(self) -> Tuple[Status, Dict[str, Any]]: - """Check the status of compilation, and finish if succeeded.""" - if self.submission.source_content is None: - return FAILED, {'reason': 'Submission has no source package'} - status: Status = self.status or IN_PROGRESS - extra: Dict[str, Any] = {} - comp: Optional[Compilation] = None - c = Compiler.current_session() - if status not in [SUCCEEDED, FAILED]: - try: - comp = c.get_status(self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token) - extra.update({'compilation': comp}) - except NotFound: # Nothing to do. - return NOT_STARTED, extra - - # Ship the product to preview and confirm processing. We only want to - # do this once. The pre-check will have set a status if it is known - # ahead of time. - if status is IN_PROGRESS and comp is not None and comp.is_succeeded: - # Ship the compiled PDF off to the preview service. - prod = c.get_product(self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token) - self.finish(prod.stream, prod.checksum) - status = SUCCEEDED - elif comp is not None and comp.is_failed: - status = FAILED - extra.update({'reason': comp.reason.value, - 'description': comp.description}) - - # Get the log output for both success and failure. - log_output: Optional[str] = None - if status in [SUCCEEDED, FAILED]: - try: - log = c.get_log(self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token) - log_output = log.stream.read().decode('utf-8') - except NotFound: - log_output = None - extra.update({'log_output': log_output}) - return status, extra - - -def _make_process(supports: SubmissionContent.Format, starter: Type[IProcess], - checker: Type[IProcess]) -> SourceProcess: - - proc = SourceProcess(supports, starter, checker) - _PROCESSES[supports] = proc - return proc - - -def _get_process(source_format: SubmissionContent.Format) -> SourceProcess: - proc = _PROCESSES.get(source_format, None) - if proc is None: - raise NotImplementedError(f'No process found for {source_format}') - return proc - - -def _get_and_call_starter(submission: Submission, user: User, - client: Optional[Client], token: str) -> CheckResult: - assert submission.source_content is not None - proc = _get_process(submission.source_content.source_format) - return proc.start(submission, user, client, token)() - - -def _get_and_call_checker(submission: Submission, user: User, - client: Optional[Client], token: str) -> CheckResult: - assert submission.source_content is not None - proc = _get_process(submission.source_content.source_format) - return proc.check(submission, user, client, token)() - - -def start(submission: Submission, user: User, client: Optional[Client], - token: str) -> CheckResult: - """ - Start processing the source package for a submission. - - Parameters - ---------- - submission : :class:`.Submission` - The submission to process. - user : :class:`.User` - arXiv user who originated the request. - client : :class:`.Client` or None - API client that handled the request, if any. - token : str - Authn/z token for the request. - - Returns - ------- - :class:`.CheckResult` - Status indicates the disposition of the process. - - Raises - ------ - :class:`NotImplementedError` - Raised if the submission source format is not supported by this module. - - """ - return _get_and_call_starter(submission, user, client, token) - - -def check(submission: Submission, user: User, client: Optional[Client], - token: str) -> CheckResult: - """ - Check the status of source processing for a submission. - - Parameters - ---------- - submission : :class:`.Submission` - The submission to process. - user : :class:`.User` - arXiv user who originated the request. - client : :class:`.Client` or None - API client that handled the request, if any. - token : str - Authn/z token for the request. - - Returns - ------- - :class:`.CheckResult` - Status indicates the disposition of the process. - - Raises - ------ - :class:`NotImplementedError` - Raised if the submission source format is not supported by this module. - - """ - return _get_and_call_checker(submission, user, client, token) - - -TeXProcess = _make_process(SubmissionContent.Format.TEX, - _CompilationStarter, - _CompilationChecker) -"""Support for processing TeX submissions.""" - - -PostscriptProcess = _make_process(SubmissionContent.Format.POSTSCRIPT, - _CompilationStarter, - _CompilationChecker) -"""Support for processing Postscript submissions.""" - - -PDFProcess = _make_process(SubmissionContent.Format.PDF, - _PDFStarter, - _PDFChecker) -"""Support for processing PDF submissions.""" diff --git a/src/arxiv/submission/process/tests.py b/src/arxiv/submission/process/tests.py deleted file mode 100644 index 5b47f36..0000000 --- a/src/arxiv/submission/process/tests.py +++ /dev/null @@ -1,537 +0,0 @@ -"""Tests for :mod:`.process.process_source`.""" - -import io -from datetime import datetime -from unittest import TestCase, mock - -from pytz import UTC - -from arxiv.integration.api.exceptions import RequestFailed, NotFound - -from ..domain import Submission, SubmissionContent, User, Client -from ..domain.event import ConfirmSourceProcessed, UnConfirmSourceProcessed -from ..domain.preview import Preview -from . import process_source -from .. import SaveError -from .process_source import start, check, SUCCEEDED, FAILED, IN_PROGRESS, \ - NOT_STARTED - -PDF = SubmissionContent.Format.PDF -TEX = SubmissionContent.Format.TEX - - -def raise_RequestFailed(*args, **kwargs): - raise RequestFailed('foo', mock.MagicMock()) - - -def raise_NotFound(*args, **kwargs): - raise NotFound('foo', mock.MagicMock()) - - -class PDFFormatTest(TestCase): - """Test case for PDF format processing.""" - - def setUp(self): - """We have a submission with a PDF source package.""" - self.content = mock.MagicMock(spec=SubmissionContent, - identifier=1234, - checksum='foochex==', - source_format=PDF) - self.submission = mock.MagicMock(spec=Submission, - submission_id=42, - source_content=self.content, - is_source_processed=False, - preview=None) - self.user = mock.MagicMock(spec=User) - self.client = mock.MagicMock(spec=Client) - self.token = 'footoken' - - -class TestStartProcessingPDF(PDFFormatTest): - """Test :const:`.PDFProcess`.""" - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Filemanager') - def test_start(self, mock_Filemanager, mock_PreviewService, mock_save): - """Start processing the PDF source.""" - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = False - mock_preview_service.deposit.return_value = mock.MagicMock( - spec=Preview, - source_id=1234, - source_checksum='foochex==', - preview_checksum='foochex==', - size_bytes=1234578, - added=datetime.now(UTC) - ) - mock_PreviewService.current_session.return_value = mock_preview_service - - mock_filemanager = mock.MagicMock() - stream = io.BytesIO(b'fakecontent') - mock_filemanager.get_single_file.return_value = ( - stream, - 'foochex==', - 'contentchex==' - ) - mock_Filemanager.current_session.return_value = mock_filemanager - - mock_save.return_value = (self.submission, []) - - data = start(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") - - mock_preview_service.deposit.assert_called_once_with( - self.content.identifier, - self.content.checksum, - stream, - self.token, - content_checksum='contentchex==', - overwrite=True - ) - - mock_save.assert_called_once() - args, kwargs = mock_save.call_args - self.assertIsInstance(args[0], ConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Filemanager') - def test_already_done(self, mock_Filemanager, mock_PreviewService, - mock_save): - """Attempt to start processing a source that is already processed.""" - self.submission.is_source_processed = True - self.submission.preview = mock.MagicMock() - self.submission.source_content.checksum = 'foochex==' - - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = False - mock_preview_service.deposit.return_value = mock.MagicMock( - spec=Preview, - source_id=1234, - source_checksum='foochex==', - preview_checksum='pvwchex==', - size_bytes=1234578, - added=datetime.now(UTC) - ) - mock_PreviewService.current_session.return_value = mock_preview_service - - mock_filemanager = mock.MagicMock() - stream = io.BytesIO(b'fakecontent') - mock_filemanager.get_single_file.return_value = ( - stream, - 'foochex==', - 'pvwchex==' - ) - mock_Filemanager.current_session.return_value = mock_filemanager - - mock_save.return_value = (self.submission, []) - - data = start(self.submission, self.user, self.client, self.token) - - self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") - - # Evaluate deposit to preview service. - mock_preview_service.deposit.assert_called_once_with( - self.content.identifier, - self.content.checksum, - stream, - self.token, - content_checksum='pvwchex==', - overwrite=True - ) - - # Evaluate calls to save() - self.assertEqual(mock_save.call_count, 2, 'Save called twice') - calls = mock_save.call_args_list - # First call is to unconfirm processing. - args, kwargs = calls[0] - self.assertIsInstance(args[0], UnConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) - - # Second call is to confirm processing. - args, kwargs = calls[1] - self.assertIsInstance(args[0], ConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) - - @mock.patch(f'{process_source.__name__}.Filemanager') - def test_start_preview_fails(self, mock_Filemanager): - """No single PDF file available to use.""" - mock_filemanager = mock.MagicMock() - stream = io.BytesIO(b'fakecontent') - mock_filemanager.get_single_file.side_effect = raise_NotFound - mock_Filemanager.current_session.return_value = mock_filemanager - - data = start(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, FAILED, 'Failed to start') - - mock_filemanager.get_single_file.assert_called_once_with( - self.content.identifier, - self.token - ) - - -class TestCheckPDF(PDFFormatTest): - """Test :const:`.PDFProcess`.""" - - @mock.patch(f'{process_source.__name__}.PreviewService') - def test_check_successful(self, mock_PreviewService): - """Source is processed and a preview is present.""" - self.submission.is_source_processed = True - self.submission.preview = mock.MagicMock( - spec=Preview, - source_id=1234, - source_checksum='foochex==', - preview_checksum='foochex==', - size_bytes=1234578, - added=datetime.now(UTC) - ) - - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = True - mock_PreviewService.current_session.return_value = mock_preview_service - - data = check(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, SUCCEEDED) - self.assertIn('preview', data.extra) - - mock_preview_service.has_preview.assert_called_once_with( - self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token, - self.submission.preview.preview_checksum - ) - - @mock.patch(f'{process_source.__name__}.PreviewService') - def test_check_preview_not_found(self, mock_PreviewService): - """Source is not processed, and there is no preview.""" - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = False - mock_preview_service.get_metadata.side_effect = raise_NotFound - mock_PreviewService.current_session.return_value = mock_preview_service - - data = check(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, NOT_STARTED) - self.assertNotIn('preview', data.extra) - - mock_preview_service.get_metadata.assert_called_once_with( - self.submission.source_content.identifier, - self.submission.source_content.checksum, - self.token - ) - - -class TeXFormatTestCase(TestCase): - """Test case for TeX format processing.""" - - def setUp(self): - """We have a submission with a TeX source package.""" - self.content = mock.MagicMock(spec=SubmissionContent, - identifier=1234, - checksum='foochex==', - source_format=TEX) - self.submission = mock.MagicMock(spec=Submission, - submission_id=42, - source_content=self.content, - is_source_processed=False, - preview=None) - self.submission.primary_classification.category = 'cs.DL' - self.user = mock.MagicMock(spec=User) - self.client = mock.MagicMock(spec=Client) - self.token = 'footoken' - - -class TestStartTeX(TeXFormatTestCase): - """Test the start of processing a TeX source.""" - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_start(self, mock_Compiler): - """Start is successful, in progress.""" - mock_compiler = mock.MagicMock() - mock_compiler.compile.return_value = mock.MagicMock(is_failed=False, - is_succeeded=False) - mock_Compiler.current_session.return_value = mock_compiler - data = start(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, IN_PROGRESS, "Processing is in progress") - - mock_compiler.compile.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token, - 'arXiv:submit/42 [cs.DL]', - '/42/preview.pdf', - force=True - ) - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_start_failed(self, mock_Compiler): - """Compilation starts, but fails immediately.""" - mock_compiler = mock.MagicMock() - mock_compiler.compile.return_value = mock.MagicMock(is_failed=True, - is_succeeded=False) - mock_Compiler.current_session.return_value = mock_compiler - with self.assertRaises(process_source.FailedToStart): - start(self.submission, self.user, self.client, self.token) - - mock_compiler.compile.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token, - 'arXiv:submit/42 [cs.DL]', - '/42/preview.pdf', - force=True - ) - - -class TestCheckTeX(TeXFormatTestCase): - """Test the status check for processing a TeX source.""" - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_in_progress(self, mock_Compiler): - """Check processing, still in progress""" - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=False, - is_failed=False) - mock_compiler.get_status.return_value = mock_compilation - mock_Compiler.current_session.return_value = mock_compiler - - data = check(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, IN_PROGRESS, "Processing is in progress") - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_nonexistant(self, mock_Compiler): - """Check processing for no such compilation.""" - mock_compiler = mock.MagicMock() - mock_compiler.get_status.side_effect = raise_NotFound - mock_Compiler.current_session.return_value = mock_compiler - data = check(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, NOT_STARTED, 'Process not started') - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_exception(self, mock_Compiler): - """Compiler service raises an exception""" - mock_compiler = mock.MagicMock() - mock_compiler.get_status.side_effect = RuntimeError - mock_Compiler.current_session.return_value = mock_compiler - with self.assertRaises(process_source.FailedToCheckStatus): - check(self.submission, self.user, self.client, self.token) - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_failed(self, mock_Compiler): - """Check processing, compilation failed.""" - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=False, - is_failed=True) - mock_compiler.get_status.return_value = mock_compilation - mock_Compiler.current_session.return_value = mock_compiler - - data = check(self.submission, self.user, self.client, self.token) - self.assertEqual(data.status, FAILED, "Processing failed") - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_succeeded(self, mock_Compiler, mock_PreviewService, - mock_save): - """Check processing, compilation succeeded.""" - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = False - mock_PreviewService.current_session.return_value = mock_preview_service - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=True, - is_failed=False) - mock_compiler.get_status.return_value = mock_compilation - stream = io.BytesIO(b'foobytes') - mock_compiler.get_product.return_value = mock.MagicMock( - stream=stream, - checksum='chx' - ) - mock_Compiler.current_session.return_value = mock_compiler - - mock_save.return_value = (self.submission, []) - - data = check(self.submission, self.user, self.client, self.token) - - self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - mock_compiler.get_product.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - mock_preview_service.deposit.assert_called_once_with( - self.content.identifier, - self.content.checksum, - stream, - self.token, - content_checksum='chx', - overwrite=True - ) - mock_save.assert_called_once() - args, kwargs = mock_save.call_args - self.assertIsInstance(args[0], ConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Compiler') - def test_succeeded_preview_shipped_not_marked(self, mock_Compiler, - mock_PreviewService, - mock_save): - """Preview already shipped, but submission not updated.""" - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = True - mock_PreviewService.current_session.return_value = mock_preview_service - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=True, - is_failed=False) - mock_compiler.get_status.return_value = mock_compilation - stream = io.BytesIO(b'foobytes') - mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, - checksum='chx') - mock_Compiler.current_session.return_value = mock_compiler - - mock_save.return_value = (self.submission, []) - - data = check(self.submission, self.user, self.client, self.token) - - self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") - - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - - mock_compiler.get_product.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - mock_preview_service.deposit.assert_called_once_with( - self.content.identifier, - self.content.checksum, - stream, - self.token, - content_checksum='chx', - overwrite=True - ) - - mock_save.assert_called_once() - args, kwargs = mock_save.call_args - self.assertIsInstance(args[0], ConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Compiler') - def test_succeeded_preview_shipped_and_marked(self, mock_Compiler, - mock_PreviewService, - mock_save): - """Preview already shipped and submission is up to date.""" - self.submission.preview = mock.MagicMock( - source_id=self.content.identifier, - checksum=self.content.checksum, - preview_checksum='chx' - ) - self.submission.is_source_processed = True - - mock_preview_service = mock.MagicMock() - mock_preview_service.has_preview.return_value = True - mock_PreviewService.current_session.return_value = mock_preview_service - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=True, - is_failed=False) - mock_compiler.get_status.return_value = mock_compilation - stream = io.BytesIO(b'foobytes') - mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, - checksum='chx') - mock_Compiler.current_session.return_value = mock_compiler - - mock_save.return_value = (self.submission, []) - - data = check(self.submission, self.user, self.client, self.token) - - self.assertEqual(data.status, SUCCEEDED, "Processing succeeded") - - mock_compiler.get_status.assert_not_called() - mock_compiler.get_product.assert_not_called() - mock_preview_service.deposit.assert_not_called() - mock_save.assert_not_called() - - @mock.patch(f'{process_source.__name__}.save') - @mock.patch(f'{process_source.__name__}.PreviewService') - @mock.patch(f'{process_source.__name__}.Compiler') - def test_check_succeeded_save_error(self, mock_Compiler, - mock_PreviewService, - mock_save): - """Compilation succeeded, but could not save event.""" - mock_preview_service = mock.MagicMock() - mock_PreviewService.current_session.return_value = mock_preview_service - mock_compiler = mock.MagicMock() - mock_compilation = mock.MagicMock(is_succeeded=True, - is_failed=False) - mock_compiler.get_status.return_value = mock_compilation - stream = io.BytesIO(b'foobytes') - mock_compiler.get_product.return_value = mock.MagicMock(stream=stream, - checksum='chx') - mock_Compiler.current_session.return_value = mock_compiler - - mock_save.side_effect = SaveError - - with self.assertRaises(process_source.FailedToCheckStatus): - check(self.submission, self.user, self.client, self.token) - - mock_compiler.get_status.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - mock_compiler.get_product.assert_called_once_with( - self.content.identifier, - self.content.checksum, - self.token - ) - mock_preview_service.deposit.assert_called_once_with( - self.content.identifier, - self.content.checksum, - stream, - self.token, - content_checksum='chx', - overwrite=True - ) - mock_save.assert_called_once() - args, kwargs = mock_save.call_args - self.assertIsInstance(args[0], ConfirmSourceProcessed) - self.assertEqual(kwargs['submission_id'], - self.submission.submission_id) \ No newline at end of file diff --git a/src/arxiv/submission/schedule.py b/src/arxiv/submission/schedule.py deleted file mode 100644 index a78e2c9..0000000 --- a/src/arxiv/submission/schedule.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Policies for announcement scheduling. - -Submissions to arXiv are normally made public on Sunday through Thursday, with -no announcements Friday or Saturday. - -+-----------------------+----------------+------------------------------------+ -| Received Between (ET) | Announced (ET) | Mailed | -+=======================+================+====================================+ -| Mon 14:00 - Tue 14:00 | Tue 20:00 | Tuesday Night / Wednesday Morning | -| Tue 14:00 - Wed 14:00 | Wed 20:00 | Wednesday Night / Thursday Morning | -| Wed 14:00 - Thu 14:00 | Thu 20:00 | Thursday Night / Friday Morning | -| Thu 14:00 - Fri 14:00 | Sun 20:00 | Sunday Night / Monday Morning | -| Fri 14:00 - Mon 14:00 | Mon 20:00 | Monday Night / Tuesday Morning | -+-----------------------+----------------+------------------------------------+ - -""" - -from typing import Optional -from datetime import datetime, timedelta -from enum import IntEnum, Enum -from pytz import timezone, UTC - -ET = timezone('US/Eastern') - - -# I preferred the callable construction of IntEnum to the class-based -# construction, but this is more typing-friendly. -class Weekdays(IntEnum): - """Numeric representation of the days of the week.""" - - Mon = 1 - Tue = 2 - Wed = 3 - Thu = 4 - Fri = 5 - Sat = 6 - Sun = 7 - - -ANNOUNCE_TIME = 20 # Hours (8pm ET) -FREEZE_TIME = 14 # Hours (2pm ET) - -WINDOWS = [ - ((Weekdays.Fri - 7, 14), (Weekdays.Mon, 14), (Weekdays.Mon, 20)), - ((Weekdays.Mon, 14), (Weekdays.Tue, 14), (Weekdays.Tue, 20)), - ((Weekdays.Tue, 14), (Weekdays.Wed, 14), (Weekdays.Wed, 20)), - ((Weekdays.Wed, 14), (Weekdays.Thu, 14), (Weekdays.Thu, 20)), - ((Weekdays.Thu, 14), (Weekdays.Fri, 14), (Weekdays.Sun, 20)), - ((Weekdays.Fri, 14), (Weekdays.Mon + 7, 14), (Weekdays.Mon + 7, 20)), -] - - -def _datetime(ref: datetime, isoweekday: int, hour: int) -> datetime: - days_hence = isoweekday - ref.isoweekday() - # repl = dict(hour=hour, minute=0, second=0, microsecond=0) - dt = (ref + timedelta(days=days_hence)) - return dt.replace(hour=hour, minute=0, second=0, microsecond=0) - - -def next_announcement_time(ref: Optional[datetime] = None) -> datetime: - """Get the datetime of the next announcement.""" - if ref is None: - ref = ET.localize(datetime.now()) - else: - ref = ref.astimezone(ET) - for start, end, announce in WINDOWS: - if _datetime(ref, *start) <= ref < _datetime(ref, *end): - return _datetime(ref, *announce) - raise RuntimeError('Could not arrive at next announcement time') - - -def next_freeze_time(ref: Optional[datetime] = None) -> datetime: - """Get the datetime of the next freeze.""" - if ref is None: - ref = ET.localize(datetime.now()) - else: - ref = ref.astimezone(ET) - for start, end, announce in WINDOWS: - if _datetime(ref, *start) <= ref < _datetime(ref, *end): - return _datetime(ref, *end) - raise RuntimeError('Could not arrive at next freeze time') diff --git a/src/arxiv/submission/serializer.py b/src/arxiv/submission/serializer.py deleted file mode 100644 index e79253a..0000000 --- a/src/arxiv/submission/serializer.py +++ /dev/null @@ -1,97 +0,0 @@ -"""JSON serialization for submission core.""" - -import json -from datetime import datetime, date -from enum import Enum -from importlib import import_module -from json.decoder import JSONDecodeError -from typing import Any, Union, List - -from backports.datetime_fromisoformat import MonkeyPatch -from dataclasses import asdict - -from arxiv.util.serialize import ISO8601JSONEncoder, ISO8601JSONDecoder - -from .domain import Event, event_factory, Submission, Agent, agent_factory - -MonkeyPatch.patch_fromisoformat() - - -class EventJSONEncoder(ISO8601JSONEncoder): - """Encodes domain objects in this package for serialization.""" - - def default(self, obj: object) -> Any: - """Look for domain objects, and use their dict-coercion methods.""" - if isinstance(obj, Event): - data = asdict(obj) - data['__type__'] = 'event' - elif isinstance(obj, Submission): - data = asdict(obj) - data.pop('before', None) - data.pop('after', None) - data['__type__'] = 'submission' - elif isinstance(obj, Agent): - data = asdict(obj) - data['__type__'] = 'agent' - elif isinstance(obj, type): - data = {} - data['__module__'] = obj.__module__ - data['__name__'] = obj.__name__ - data['__type__'] = 'type' - elif isinstance(obj, Enum): - data = obj.value - else: - data = super(EventJSONEncoder, self).default(obj) - return data - - -class EventJSONDecoder(ISO8601JSONDecoder): - """Decode :class:`.Event` and other domain objects from JSON data.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Pass :func:`object_hook` to the base constructor.""" - kwargs['object_hook'] = kwargs.get('object_hook', self.object_hook) - super(EventJSONDecoder, self).__init__(*args, **kwargs) - - def object_hook(self, obj: dict, **extra: Any) -> Any: - """Decode domain objects in this package.""" - obj = super(EventJSONDecoder, self).object_hook(obj, **extra) - - if '__type__' in obj: - if obj['__type__'] == 'event': - obj.pop('__type__') - return event_factory(obj.pop('event_type'), - obj.pop('created'), - **obj) - elif obj['__type__'] == 'submission': - obj.pop('__type__') - return Submission(**obj) - elif obj['__type__'] == 'agent': - obj.pop('__type__') - return agent_factory(**obj) - elif obj['__type__'] == 'type': - # Supports deserialization of Event classes. - # - # This is fairly dangerous, since we are importing and calling - # an arbitrary object specified in data. We need to be sure to - # check that the object originates in this package, and that it - # is actually a child of Event. - module_name = obj['__module__'] - if not (module_name.startswith('arxiv.submission') - or module_name.startswith('submission')): - raise JSONDecodeError(module_name, '', pos=0) - cls = getattr(import_module(module_name), obj['__name__']) - if Event not in cls.mro(): - raise JSONDecodeError(obj['__name__'], '', pos=0) - return cls - return obj - - -def dumps(obj: Any) -> str: - """Generate JSON from a Python object.""" - return json.dumps(obj, cls=EventJSONEncoder) - - -def loads(data: str) -> Any: - """Load a Python object from JSON.""" - return json.loads(data, cls=EventJSONDecoder) diff --git a/src/arxiv/submission/services/__init__.py b/src/arxiv/submission/services/__init__.py deleted file mode 100644 index f6d2fe2..0000000 --- a/src/arxiv/submission/services/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""External service integrations.""" - -from .classifier import Classifier -from .compiler import Compiler -from .filemanager import Filemanager -from .plaintext import PlainTextService -from .preview import PreviewService -from .stream import StreamPublisher diff --git a/src/arxiv/submission/services/classic/__init__.py b/src/arxiv/submission/services/classic/__init__.py deleted file mode 100644 index 1dc807e..0000000 --- a/src/arxiv/submission/services/classic/__init__.py +++ /dev/null @@ -1,719 +0,0 @@ -""" -Integration with the classic database to persist events and submission state. - -As part of the classic renewal strategy, development of new submission -interfaces must maintain data interoperability with classic components. This -service module must therefore do three main things: - -1. Store and provide access to event data generated during the submission - process, -2. Keep the classic database tables up to date so that "downstream" components - can continue to operate. -3. Patch NG submission data with state changes that occur in the classic - system. Those changes will be made directly to submission tables and not - involve event-generation. See :func:`get_submission` for details. - -Since classic components work directly on submission tables, persisting events -and resulting submission state must occur in the same transaction. We must also -verify that we are not storing events that are stale with respect to the -current state of the submission. To achieve this, the caller should use the -:func:`.util.transaction` context manager, and (when committing new events) -call :func:`.get_submission` with ``for_update=True``. This will trigger a -shared lock on the submission row(s) involved until the transaction is -committed or rolled back. - -ORM representations of the classic database tables involved in submission -are located in :mod:`.classic.models`. An additional model, :class:`.DBEvent`, -is defined in :mod:`.classic.event`. - -See also :ref:`legacy-integration`. - -""" - -from typing import List, Optional, Tuple, Set, Callable, Any, TypeVar, cast -from retry import retry as _retry -from datetime import datetime -from operator import attrgetter -from pytz import UTC -from itertools import groupby -import copy -import traceback -from functools import reduce, wraps -from operator import ior -from dataclasses import asdict - -from flask import Flask -from sqlalchemy import or_, text -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.exc import DBAPIError, OperationalError - -from arxiv.base import logging -from arxiv.base.globals import get_application_config, get_application_global -from ...domain.event import Event, Announce, RequestWithdrawal, SetDOI, \ - SetJournalReference, SetReportNumber, Rollback, RequestCrossList, \ - ApplyRequest, RejectRequest, ApproveRequest, AddProposal, CancelRequest, \ - CreateSubmission - -from ...domain.submission import License, Submission, WithdrawalRequest, \ - CrossListClassificationRequest -from ...domain.agent import Agent, User -from .models import Base -from .exceptions import ClassicBaseException, NoSuchSubmission, \ - TransactionFailed, Unavailable, ConsistencyError -from .util import transaction, current_session, db -from .event import DBEvent -from . import models, util, interpolate, log, proposal, load - - -logger = logging.getLogger(__name__) -logger.propagate = False - -JREFEvents = [SetDOI, SetJournalReference, SetReportNumber] - -FuncType = Callable[..., Any] -F = TypeVar('F', bound=FuncType) - -# retry = _retry -retry: Callable[..., Callable[[F], F]] = _retry -# wraps: Callable[[F], F] = _wraps - - -def handle_operational_errors(func: F) -> F: - """Catch SQLAlchemy OperationalErrors and raise :class:`.Unavailable`.""" - @wraps(func) - def inner(*args: Any, **kwargs: Any) -> Any: - try: - return func(*args, **kwargs) - except OperationalError as e: - logger.error('Encountered an OperationalError calling %s', - func.__name__) - # This will put the traceback in the log, and it may look like an - # unhandled exception (even though it is not). - logger.error('==== OperationalError: handled traceback start ====') - logger.error(traceback.format_exc()) - logger.error('==== OperationalError: handled traceback end ====') - raise Unavailable('Classic database unavailable') from e - # return inner - return cast(F, inner) - - -def is_available(**kwargs: Any) -> bool: - """Check our connection to the database.""" - try: - logger.info('Checking Classic is available') - _check_available() - except Unavailable as e: - logger.info('Database not available: %s', e) - return False - return True - - -@handle_operational_errors -def _check_available() -> None: - """Execute ``SELECT 1`` against the database.""" - #current_session().query("1").from_statement(text("SELECT 1")).all() - with current_session() as session: - session.execute('SELECT 1') - -@retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_licenses() -> List[License]: - """Get a list of :class:`.domain.License` instances available.""" - license_data = current_session().query(models.License) \ - .filter(models.License.active == '1') - return [License(uri=row.name, name=row.label) for row in license_data] - - -@retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_events(submission_id: int) -> List[Event]: - """ - Load events from the classic database. - - Parameters - ---------- - submission_id : int - - Returns - ------- - list - Items are :class:`.Event` instances loaded from the class DB. - - Raises - ------ - :class:`.classic.exceptions.NoSuchSubmission` - Raised when there are no events for the provided submission ID. - - """ - session = current_session() - event_data = session.query(DBEvent) \ - .filter(DBEvent.submission_id == submission_id) \ - .order_by(DBEvent.created) - events = [datum.to_event() for datum in event_data] - if not events: # No events, no dice. - logger.error('No events for submission %s', submission_id) - raise NoSuchSubmission(f'Submission {submission_id} not found') - return events - - -@retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_user_submissions_fast(user_id: int) -> List[Submission]: - """ - Get active NG submissions for a user. - - This should not return submissions for which there are no events. - - Uses the same approach as :func:`get_submission_fast`. - - Parameters - ---------- - submission_id : int - - Returns - ------- - list - Items are the user's :class:`.domain.submission.Submission` instances. - - """ - session = current_session() - db_submissions = list( - session.query(models.Submission) - .filter(models.Submission.submitter_id == user_id) - .join(DBEvent) # Only get submissions that are also in the event table - .order_by(models.Submission.doc_paper_id.desc()) - ) - grouped = groupby(db_submissions, key=attrgetter('doc_paper_id')) - submissions: List[Optional[Submission]] = [] - for arxiv_id, dbss in grouped: - logger.debug('Handle group for arXiv ID %s: %s', arxiv_id, dbss) - if arxiv_id is None: # This is an unannounced submission. - for dbs in dbss: # Each row represents a separate e-print. - submissions.append(load.to_submission(dbs)) - else: - submissions.append( - load.load(sorted(dbss, key=lambda dbs: dbs.submission_id)) - ) - return [subm for subm in submissions if subm and not subm.is_deleted] - - -@retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_submission_fast(submission_id: int) -> Submission: - """ - Get the projection of the submission directly. - - Instead of playing events forward, we grab the most recent snapshot of the - submission in the database. Since classic represents the submission using - several rows, we have to grab all of them and transform/patch as - appropriate. - - Parameters - ---------- - submission_id : int - - Returns - ------- - :class:`.domain.submission.Submission` or ``None`` - - Raises - ------ - :class:`.classic.exceptions.NoSuchSubmission` - Raised when there are is no submission for the provided submission ID. - - """ - submission = load.load(_get_db_submission_rows(submission_id)) - if submission is None: - raise NoSuchSubmission(f'No submission found: {submission_id}') - return submission - - -# @retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_submission(submission_id: int, for_update: bool = False) \ - -> Tuple[Submission, List[Event]]: - """ - Get the current state of a submission from the database. - - In the medium term, services that use this package will need to - play well with legacy services that integrate with the classic - database. For example, the moderation system does not use the event - model implemented here, and will therefore cause direct changes to the - submission tables that must be reflected in our representation of the - submission. - - Until those legacy components are replaced, this function loads both the - event stack and the current DB state of the submission, and uses the DB - state to patch fields that may have changed outside the purview of the - event model. - - Parameters - ---------- - submission_id : int - - Returns - ------- - :class:`.domain.submission.Submission` - list - Items are :class:`Event` instances. - - """ - # Let the caller determine the transaction scope. - session = current_session() - original_row = session.query(models.Submission) \ - .filter(models.Submission.submission_id == submission_id) \ - .join(DBEvent) - - if for_update: - # Gives us SELECT ... FOR READ. In other words, lock this row for - # writing, but allow other clients to read from it in the meantime. - original_row = original_row.with_for_update(read=True) - - try: - original_row = original_row.one() - logger.debug('Got row %s', original_row) - except NoResultFound as exc: - logger.debug('Got NoResultFound exception %s', exc) - raise NoSuchSubmission(f'Submission {submission_id} not found') - # May also raise MultipleResultsFound; if so, we want to fail loudly. - - # Load any subsequent submission rows (e.g. v=2, jref, withdrawal). - # These do not have the same legacy submission ID as the original - # submission. - subsequent_rows: List[models.Submission] = [] - arxiv_id = original_row.get_arxiv_id() - if arxiv_id is not None: - subsequent_query = session.query(models.Submission) \ - .filter(models.Submission.doc_paper_id == arxiv_id) \ - .filter(models.Submission.submission_id != submission_id) \ - .order_by(models.Submission.submission_id.asc()) - - if for_update: # Lock these rows as well. - subsequent_query = subsequent_query.with_for_update(read=True) - subsequent_rows = list(subsequent_query) # Execute query. - logger.debug('Got subsequent_rows: %s', subsequent_rows) - - try: - _events = get_events(submission_id) - except NoSuchSubmission: - _events = [] - - # If this submission originated in the classic system, we will have usable - # rows from the submission table, and either no events or events that do - # not start with a CreateSubmission event. In that case, fall back to - # ``load.load()``, which relies only on classic rows. - if not _events or not isinstance(_events[0], CreateSubmission): - logger.info('Loading a classic submission: %s', submission_id) - submission = load.load([original_row] + subsequent_rows) - if submission is None: - raise NoSuchSubmission('No such submission') - return submission, [] - - # We have an NG-native submission. - interpolator = interpolate.ClassicEventInterpolator( - original_row, - subsequent_rows, - _events - ) - return interpolator.get_submission_state() - - -# @retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def store_event(event: Event, before: Optional[Submission], after: Submission, - *call: Callable) -> Tuple[Event, Submission]: - """ - Store an event, and update submission state. - - This is where we map the NG event domain onto the classic database. The - main differences are that: - - - In the event domain, a submission is a single stream of events, but - in the classic system we create new rows in the submission database - for things like replacements, adding DOIs, and withdrawing papers. - - In the event domain, the only concept of the announced paper is the - paper ID. In the classic submission database, we also have to worry about - the row in the Document database. - - We assume that the submission states passed to this function have the - correct paper ID and version number, if announced. The submission ID on - the event and the before/after states refer to the original classic - submission only. - - Parameters - ---------- - event : :class:`Event` - before : :class:`Submission` - The state of the submission before the event occurred. - after : :class:`Submission` - The state of the submission after the event occurred. - call : list - Items are callables that accept args ``Event, Submission, Submission``. - These are called within the transaction context; if an exception is - raised, the transaction is rolled back. - - """ - # Let the caller determine the transaction scope. - session = current_session() - if event.committed: - raise TransactionFailed('%s already committed', event.event_id) - if event.created is None: - raise ValueError('Event creation timestamp not set') - logger.debug('store event %s', event.event_type) - - doc_id: Optional[int] = None - - # This is the case that we have a new submission. - if before is None: # and isinstance(after, Submission): - dbs = models.Submission(type=models.Submission.NEW_SUBMISSION) - dbs.update_from_submission(after) - this_is_a_new_submission = True - - else: # Otherwise we're making an update for an existing submission. - this_is_a_new_submission = False - - if before.arxiv_id is not None: #: - # After the original submission is announced, a new Document row is - # created. This Document is shared by all subsequent Submission rows. - doc_id = _load_document_id(before.arxiv_id, before.version) - - # From the perspective of the database, a replacement is mainly an - # incremented version number. This requires a new row in the - # database. - if after.version > before.version: - dbs = _create_replacement(doc_id, before.arxiv_id, - after.version, after, event.created) - elif isinstance(event, Rollback) and before.version > 1: - dbs = _delete_replacement(doc_id, before.arxiv_id, - before.version) - - - # Withdrawals also require a new row, and they use the most recent - # version number. - elif isinstance(event, RequestWithdrawal): - dbs = _create_withdrawal(doc_id, event.reason, - before.arxiv_id, after.version, after, - event.created) - elif isinstance(event, RequestCrossList): - dbs = _create_crosslist(doc_id, event.categories, - before.arxiv_id, after.version, after, - event.created) - - # Adding DOIs and citation information (so-called "journal reference") - # also requires a new row. The version number is not incremented. - elif before.is_announced and type(event) in JREFEvents: - dbs = _create_jref(doc_id, before.arxiv_id, after.version, after, - event.created) - - elif isinstance(event, CancelRequest): - dbs = _cancel_request(event, before, after) - - # The submission has been announced. - elif isinstance(before, Submission) and before.arxiv_id is not None: - dbs = _load(paper_id=before.arxiv_id, version=before.version) - _preserve_sticky_hold(dbs, before, after, event) - dbs.update_from_submission(after) - else: - raise TransactionFailed("Something is fishy") - - - # The submission has not yet been announced; we're working with a - # single row. - elif isinstance(before, Submission) and before.submission_id: - dbs = _load(before.submission_id) - - _preserve_sticky_hold(dbs, before, after, event) - dbs.update_from_submission(after) - else: - raise TransactionFailed("Something is fishy") - - db_event = _new_dbevent(event) - session.add(dbs) - session.add(db_event) - - # Make sure that we get a submission ID; note that this # does not commit - # the transaction, just pushes the # SQL that we have generated so far to - # the database # server. - session.flush() - - log.handle(event, before, after) # Create admin log entry. - for func in call: - logger.debug('call %s with event %s', func, event.event_id) - func(event, before, after) - if isinstance(event, AddProposal): - assert before is not None - proposal.add(event, before, after) - - # Attach the database object for the event to the row for the - # submission. - if this_is_a_new_submission: # Update in transaction. - db_event.submission = dbs - else: # Just set the ID directly. - assert before is not None - db_event.submission_id = before.submission_id - - event.committed = True - - # Update the domain event and submission states with the submission ID. - # This should carry forward the original submission ID, even if the - # classic database has several rows for the submission (with different - # IDs). - if this_is_a_new_submission: - event.submission_id = dbs.submission_id - after.submission_id = dbs.submission_id - else: - assert before is not None - event.submission_id = before.submission_id - after.submission_id = before.submission_id - return event, after - - -@retry(ClassicBaseException, tries=3, delay=1) -@handle_operational_errors -def get_titles(since: datetime) -> List[Tuple[int, str, Agent]]: - """Get titles from submissions created on or after a particular date.""" - # TODO: consider making this a param, if we need this function for anything - # else. - STATUSES_TO_CHECK = [ - models.Submission.SUBMITTED, - models.Submission.ON_HOLD, - models.Submission.NEXT_PUBLISH_DAY, - models.Submission.REMOVED, - models.Submission.USER_DELETED, - models.Submission.DELETED_ON_HOLD, - models.Submission.DELETED_PROCESSING, - models.Submission.DELETED_REMOVED, - models.Submission.DELETED_USER_EXPIRED - ] - session = current_session() - q = session.query( - models.Submission.submission_id, - models.Submission.title, - models.Submission.submitter_id, - models.Submission.submitter_email - ) - q = q.filter(models.Submission.status.in_(STATUSES_TO_CHECK)) - q = q.filter(models.Submission.created >= since) - return [ - (submission_id, title, User(native_id=user_id, email=user_email)) - for submission_id, title, user_id, user_email in q.all() - ] - - -# Private functions down here. - -def _load(submission_id: Optional[int] = None, paper_id: Optional[str] = None, - version: Optional[int] = 1, row_type: Optional[str] = None) \ - -> models.Submission: - if row_type is not None: - limit_to = [row_type] - else: - limit_to = [models.Submission.NEW_SUBMISSION, - models.Submission.REPLACEMENT] - session = current_session() - if submission_id is not None: - submission = session.query(models.Submission) \ - .filter(models.Submission.submission_id == submission_id) \ - .filter(models.Submission.type.in_(limit_to)) \ - .one() - elif submission_id is None and paper_id is not None: - submission = session.query(models.Submission) \ - .filter(models.Submission.doc_paper_id == paper_id) \ - .filter(models.Submission.version == version) \ - .filter(models.Submission.type.in_(limit_to)) \ - .order_by(models.Submission.submission_id.desc()) \ - .first() - else: - submission = None - if submission is None: - raise NoSuchSubmission("No submission row matches those parameters") - assert isinstance(submission, models.Submission) - return submission - - -def _cancel_request(event: CancelRequest, before: Submission, - after: Submission) -> models.Submission: - assert event.request_id is not None - request = before.user_requests[event.request_id] - if isinstance(request, WithdrawalRequest): - row_type = models.Submission.WITHDRAWAL - elif isinstance(request, CrossListClassificationRequest): - row_type = models.Submission.CROSS_LIST - dbs = _load(paper_id=before.arxiv_id, version=before.version, - row_type=row_type) - dbs.status = models.Submission.USER_DELETED - return dbs - - -def _load_document_id(paper_id: str, version: int) -> int: - logger.debug('get document ID with %s and %s', paper_id, version) - session = current_session() - document_id = session.query(models.Submission.document_id) \ - .filter(models.Submission.doc_paper_id == paper_id) \ - .filter(models.Submission.version == version) \ - .first() - if document_id is None: - raise NoSuchSubmission("No submission row matches those parameters") - return int(document_id[0]) - - -def _create_replacement(document_id: int, paper_id: str, version: int, - submission: Submission, created: datetime) \ - -> models.Submission: - """ - Create a new replacement submission. - - From the perspective of the database, a replacement is mainly an - incremented version number. This requires a new row in the database. - """ - dbs = models.Submission(type=models.Submission.REPLACEMENT, - document_id=document_id, version=version) - dbs.update_from_submission(submission) - dbs.created = created - dbs.updated = created - dbs.doc_paper_id = paper_id - dbs.status = models.Submission.NOT_SUBMITTED - return dbs - - -def _delete_replacement(document_id: int, paper_id: str, version: int) \ - -> models.Submission: - session = current_session() - dbs = session.query(models.Submission) \ - .filter(models.Submission.doc_paper_id == paper_id) \ - .filter(models.Submission.version == version) \ - .filter(models.Submission.type == models.Submission.REPLACEMENT) \ - .order_by(models.Submission.submission_id.desc()) \ - .first() - dbs.status = models.Submission.USER_DELETED - assert isinstance(dbs, models.Submission) - return dbs - - -def _create_withdrawal(document_id: int, reason: str, paper_id: str, - version: int, submission: Submission, - created: datetime) -> models.Submission: - """ - Create a new withdrawal request. - - Withdrawals also require a new row, and they use the most recent version - number. - """ - dbs = models.Submission(type=models.Submission.WITHDRAWAL, - document_id=document_id, - version=version) - dbs.update_withdrawal(submission, reason, paper_id, version, created) - return dbs - - -def _create_crosslist(document_id: int, categories: List[str], paper_id: str, - version: int, submission: Submission, - created: datetime) -> models.Submission: - """ - Create a new crosslist request. - - Cross list requests also require a new row, and they use the most recent - version number. - """ - dbs = models.Submission(type=models.Submission.CROSS_LIST, - document_id=document_id, - version=version) - dbs.update_cross(submission, categories, paper_id, version, created) - return dbs - - -def _create_jref(document_id: int, paper_id: str, version: int, - submission: Submission, - created: datetime) -> models.Submission: - """ - Create a JREF submission. - - Adding DOIs and citation information (so-called "journal reference") also - requires a new row. The version number is not incremented. - """ - # Try to piggy-back on an existing JREF row. In the classic system, all - # three fields can get updated on the same row. - try: - most_recent_sb = _load(paper_id=paper_id, version=version, - row_type=models.Submission.JOURNAL_REFERENCE) - if most_recent_sb and not most_recent_sb.is_announced(): - most_recent_sb.update_from_submission(submission) - return most_recent_sb - except NoSuchSubmission: - pass - - # Otherwise, create a new JREF row. - dbs = models.Submission(type=models.Submission.JOURNAL_REFERENCE, - document_id=document_id, version=version) - dbs.update_from_submission(submission) - dbs.created = created - dbs.updated = created - dbs.doc_paper_id = paper_id - dbs.status = models.Submission.PROCESSING_SUBMISSION - return dbs - - -def _new_dbevent(event: Event) -> DBEvent: - """Create an event entry in the database.""" - return DBEvent(event_type=event.event_type, - event_id=event.event_id, - event_version=_get_app_version(), - data=asdict(event), - created=event.created, - creator=asdict(event.creator), - proxy=asdict(event.proxy) if event.proxy else None) - - -def _preserve_sticky_hold(dbs: models.Submission, before: Submission, - after: Submission, event: Event) -> None: - if dbs.status != models.Submission.ON_HOLD: - return - if dbs.is_on_hold() and after.status == Submission.WORKING: - dbs.sticky_status = models.Submission.ON_HOLD - - -def _get_app_version() -> str: - return str(get_application_config().get('CORE_VERSION', '0.0.0')) - - -def init_app(app: Flask) -> None: - """Register the SQLAlchemy extension to an application.""" - db.init_app(app) - - @app.teardown_request - def teardown_request(exception: Optional[Exception]) -> None: - if exception is not None: - db.session.rollback() - db.session.remove() - - @app.teardown_appcontext - def teardown_appcontext(*args: Any, **kwargs: Any) -> None: - db.session.rollback() - db.session.remove() - - -def create_all() -> None: - """Create all tables in the database.""" - Base.metadata.create_all(db.engine) - - -def drop_all() -> None: - """Drop all tables in the database.""" - Base.metadata.drop_all(db.engine) - - -def _get_db_submission_rows(submission_id: int) -> List[models.Submission]: - session = current_session() - head = session.query(models.Submission.submission_id, - models.Submission.doc_paper_id) \ - .filter_by(submission_id=submission_id) \ - .subquery() - dbss = list( - session.query(models.Submission) - .filter(or_(models.Submission.submission_id == submission_id, - models.Submission.doc_paper_id == head.c.doc_paper_id)) - .order_by(models.Submission.submission_id.desc()) - ) - if not dbss: - raise NoSuchSubmission('No submission found') - return dbss diff --git a/src/arxiv/submission/services/classic/bootstrap.py b/src/arxiv/submission/services/classic/bootstrap.py deleted file mode 100644 index cf0a593..0000000 --- a/src/arxiv/submission/services/classic/bootstrap.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Generate synthetic data for testing and development purposes.""" - -import random -from datetime import datetime -from typing import List, Dict, Any - -from mimesis import Person, Internet, Datetime -from mimesis import config as mimesis_config - -from arxiv import taxonomy -from . import models - -LOCALES = list(mimesis_config.SUPPORTED_LOCALES.keys()) - - -def _get_locale() -> str: - loc: str = LOCALES[random.randint(0, len(LOCALES) - 1)] - return loc - - -def _epoch(t: datetime) -> int: - return int((t - datetime.utcfromtimestamp(0)).total_seconds()) - - -LICENSES: List[Dict[str, Any]] = [ - { - "name": "", - "note": None, - "label": "None of the above licenses apply", - "active": 1, - "sequence": 99 - }, - { - "name": "http://arxiv.org/licenses/assumed-1991-2003/", - "note": "", - "label": "Assumed arXiv.org perpetual, non-exclusive license to" + - " distribute this article for submissions made before" + - " January 2004", - "active": 0, - "sequence": 9 - }, - { - "name": "http://arxiv.org/licenses/nonexclusive-distrib/1.0/", - "note": "(Minimal rights required by arXiv.org. Select this unless" + - " you understand the implications of other licenses.)", - "label": "arXiv.org perpetual, non-exclusive license to distribute" + - " this article", - "active": 1, - "sequence": 1 - }, - { - "name": "http://creativecommons.org/licenses/by-nc-sa/3.0/", - "note": "", - "label": "Creative Commons Attribution-Noncommercial-ShareAlike" + - " license", - "active": 0, - "sequence": 3 - }, - { - "name": "http://creativecommons.org/licenses/by-nc-sa/4.0/", - "note": "", - "label": "Creative Commons Attribution-Noncommercial-ShareAlike" + - " license (CC BY-NC-SA 4.0)", - "active": 1, - "sequence": 7 - }, - { - "name": "http://creativecommons.org/licenses/by-sa/4.0/", - "note": "", - "label": "Creative Commons Attribution-ShareAlike license" + - " (CC BY-SA 4.0)", - "active": 1, - "sequence": 6 - }, - { - "name": "http://creativecommons.org/licenses/by/3.0/", - "note": "", - "label": "Creative Commons Attribution license", - "active": 0, - "sequence": 2 - }, - { - "name": "http://creativecommons.org/licenses/by/4.0/", - "note": "", - "label": "Creative Commons Attribution license (CC BY 4.0)", - "active": 1, - "sequence": 5 - }, - { - "name": "http://creativecommons.org/licenses/publicdomain/", - "note": "(Suitable for US government employees, for example)", - "label": "Creative Commons Public Domain Declaration", - "active": 0, - "sequence": 4 - }, - { - "name": "http://creativecommons.org/publicdomain/zero/1.0/", - "note": "", - "label": "Creative Commons Public Domain Declaration (CC0 1.0)", - "active": 1, - "sequence": 8 - } -] - -POLICY_CLASSES = [ - {"name": "Administrator", "class_id": 1, "description": ""}, - {"name": "Public user", "class_id": 2, "description": ""}, - {"name": "Legacy user", "class_id": 3, "description": ""} -] - - -def categories() -> List[models.CategoryDef]: - """Generate data for current arXiv categories.""" - return [ - models.CategoryDef( - category=category, - name=data['name'], - active=1 - ) for category, data in taxonomy.CATEGORIES.items() - ] - - -def policy_classes() -> List[models.PolicyClass]: - """Generate policy classes.""" - return [models.PolicyClass(**datum) for datum in POLICY_CLASSES] - - -def users(count: int = 500) -> List[models.User]: - """Generate a bunch of random users.""" - _users = [] - for i in range(count): - locale = _get_locale() - person = Person(locale) - net = Internet(locale) - ip_addr = net.ip_v4() - _users.append(models.User( - first_name=person.name(), - last_name=person.surname(), - suffix_name=person.title(), - share_first_name=1, - share_last_name=1, - email=person.email(), - share_email=8, - email_bouncing=0, - policy_class=2, # Public user. - joined_date=_epoch(Datetime(locale).datetime()), - joined_ip_num=ip_addr, - joined_remote_host=ip_addr - )) - return _users - - -def licenses() -> List[models.License]: - """Generate licenses.""" - return [models.License(**datum) for datum in LICENSES] diff --git a/src/arxiv/submission/services/classic/event.py b/src/arxiv/submission/services/classic/event.py deleted file mode 100644 index 8b5bf53..0000000 --- a/src/arxiv/submission/services/classic/event.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Persistence for NG events in the classic database.""" - -from datetime import datetime -from pytz import UTC - -from sqlalchemy import Column, String, ForeignKey -from sqlalchemy.ext.indexable import index_property -from sqlalchemy.orm import relationship - -# Combining the base DateTime field with a MySQL backend does not support -# fractional seconds. Since we may be creating events only milliseconds apart, -# getting fractional resolution is essential. -from sqlalchemy.dialects.mysql import DATETIME as DateTime - -from ...domain.event import Event, event_factory -from ...domain.agent import User, Client, Agent, System, agent_factory -from .models import Base -from .util import transaction, current_session, FriendlyJSON - - -class DBEvent(Base): # type: ignore - """Database representation of an :class:`.Event`.""" - - __tablename__ = 'event' - - event_id = Column(String(40), primary_key=True) - event_type = Column(String(255)) - event_version = Column(String(20), default='0.0.0') - proxy = Column(FriendlyJSON) - proxy_id = index_property('proxy', 'agent_identifier') - client = Column(FriendlyJSON) - client_id = index_property('client', 'agent_identifier') - - creator = Column(FriendlyJSON) - creator_id = index_property('creator', 'agent_identifier') - - created = Column(DateTime(fsp=6)) - data = Column(FriendlyJSON) - submission_id = Column( - ForeignKey('arXiv_submissions.submission_id'), - index=True - ) - - submission = relationship("Submission") - - def to_event(self) -> Event: - """ - Instantiate an :class:`.Event` using event data from this instance. - - Returns - ------- - :class:`.Event` - - """ - _skip = ['creator', 'proxy', 'client', 'submission_id', 'created', - 'event_type', 'event_version'] - data = { - key: value for key, value in self.data.items() - if key not in _skip - } - data['committed'] = True # Since we're loading from the DB. - return event_factory( - event_type=self.event_type, - creator=agent_factory(**self.creator), - event_version=self.event_version, - proxy=agent_factory(**self.proxy) if self.proxy else None, - client=agent_factory(**self.client) if self.client else None, - submission_id=self.submission_id, - created=self.get_created(), - **data - ) - - def get_created(self) -> datetime: - """Get the UTC-localized creation time for this event.""" - dt: datetime = self.created.replace(tzinfo=UTC) - return dt diff --git a/src/arxiv/submission/services/classic/exceptions.py b/src/arxiv/submission/services/classic/exceptions.py deleted file mode 100644 index 8f37c99..0000000 --- a/src/arxiv/submission/services/classic/exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Exceptions raised by :mod:`arxiv.submission.services.classic`.""" - - -class ClassicBaseException(RuntimeError): - """Base for classic service exceptions.""" - - -class NoSuchSubmission(ClassicBaseException): - """A request was made for a submission that does not exist.""" - - -class TransactionFailed(ClassicBaseException): - """Raised when there was a problem committing changes to the database.""" - - -class Unavailable(ClassicBaseException): - """The classic data store is not available.""" - - -class ConsistencyError(ClassicBaseException): - """Attempted to persist stale or inconsistent state.""" diff --git a/src/arxiv/submission/services/classic/interpolate.py b/src/arxiv/submission/services/classic/interpolate.py deleted file mode 100644 index 1759f78..0000000 --- a/src/arxiv/submission/services/classic/interpolate.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -Inject events from outside the scope of the NG submission system. - -A core concept of the :mod:`arxiv.submission.domain.event` model is that -the state of a submission can be obtained by playing forward all of the -commands/events applied to it. That works when all agents that operate -on submission state are generating commands. The problem that we face in -the short term is that some operations will be performed by legacy components -that don't generate command/event data. - -The objective of the :class:`ClassicEventInterpolator` is to reconcile -NG events/commands with aspects of the classic database that are outside its -current purview. The logic in this module will need to change as the scope -of the NG submission data architecture expands. -""" - -from typing import List, Optional, Dict, Tuple, Any, Type -from datetime import datetime - -from arxiv.base import logging -from arxiv import taxonomy -from . import models -from ...domain.submission import Submission, UserRequest, WithdrawalRequest, \ - CrossListClassificationRequest, Hold -from ...domain.event import Event, SetDOI, SetJournalReference, \ - SetReportNumber, ApplyRequest, RejectRequest, Announce, AddHold, \ - CancelRequest, SetPrimaryClassification, AddSecondaryClassification, \ - SetTitle, SetAbstract, SetComments, SetMSCClassification, \ - SetACMClassification, SetAuthors, Reclassify, ConfirmSourceProcessed - -from ...domain.agent import System, User -from .load import status_from_classic - - -logger = logging.getLogger(__name__) -logger.propagate = False -SYSTEM = System(__name__) - - -class ClassicEventInterpolator: - """Interleaves events with classic data to get the current state.""" - - def __init__(self, current_row: models.Submission, - subsequent_rows: List[models.Submission], - events: List[Event]) -> None: - """Interleave events with classic data to get the current state.""" - self.applied_events: List[Event] = [] - self.current_row: Optional[models.Submission] = current_row - self.db_rows = subsequent_rows - logger.debug("start with current row: %s", self.current_row) - logger.debug("start with subsequent rows: %s", - [(d.type, d.status) for d in self.db_rows]) - self.events = events - self.submission_id = current_row.submission_id - # We always start from the beginning (no submission). - self.submission: Optional[Submission] = None - self.arxiv_id = self.current_row.get_arxiv_id() - - self.requests = { - WithdrawalRequest: 0, - CrossListClassificationRequest: 0 - } - - @property - def next_row(self) -> models.Submission: - """Access the next classic database row for this submission.""" - return self.db_rows[0] - - def _insert_request_event(self, rq_class: Type[UserRequest], - event_class: Type[Event]) -> None: - """Create and apply a request-related event.""" - assert self.submission is not None and self.current_row is not None - logger.debug('insert request event, %s, %s', - rq_class.__name__, event_class.__name__) - # Mypy still chokes on these dataclass params. - event = event_class( # type: ignore - creator=SYSTEM, - created=self.current_row.get_updated(), - committed=True, - request_id=rq_class.generate_request_id(self.submission) - ) - self._apply(event) - # self.current_row.get_created(), - # rq_class.__name__, - # self.current_row.get_submitter() - # ) - - def _current_row_preceeds_event(self, event: Event) -> bool: - assert self.current_row is not None and event.created is not None - delta = self.current_row.get_updated() - event.created - # Classic lacks millisecond precision. - return (delta).total_seconds() < -1 - - def _should_advance_to_next_row(self, event: Event) -> bool: - if self._there_are_rows_remaining(): - assert self.next_row is not None and event.created is not None - return bool(self.next_row.get_created() <= event.created) - return False - - def _there_are_rows_remaining(self) -> bool: - return len(self.db_rows) > 0 - - def _advance_to_next_row(self) -> None: - assert self.submission is not None and self.current_row is not None - if self.current_row.is_withdrawal(): - self.requests[WithdrawalRequest] += 1 - if self.current_row.is_crosslist(): - self.requests[CrossListClassificationRequest] += 1 - try: - self.current_row = self.db_rows.pop(0) - except IndexError: - self.current_row = None - - def _can_inject_from_current_row(self) -> bool: - assert self.current_row is not None - return bool( - self.current_row.version == 1 - or (self.current_row.is_jref() - and not self.current_row.is_deleted()) - or self.current_row.is_withdrawal() - or self.current_row.is_crosslist() - or (self.current_row.is_new_version() - and not self.current_row.is_deleted()) - ) - - def _should_backport(self, event: Event) -> bool: - """Evaluate if this event be applied to the last announced version.""" - assert self.submission is not None and self.current_row is not None - return bool( - type(event) in [SetDOI, SetJournalReference, SetReportNumber] - and self.submission.versions - and self.submission.version - == self.submission.versions[-1].version - ) - - def _inject_from_current_row(self) -> None: - assert self.current_row is not None - if self.current_row.is_new_version(): - # Apply any holds created in the admin or moderation system. - if self.current_row.status == models.Submission.ON_HOLD: - self._inject(AddHold, hold_type=Hold.Type.PATCH) - - # TODO: these need some explicit event/command representations. - elif self.submission is not None: - if status_from_classic(self.current_row.status) \ - == Submission.SCHEDULED: - self.submission.status = Submission.SCHEDULED - elif status_from_classic(self.current_row.status) \ - == Submission.DELETED: - self.submission.status = Submission.DELETED - elif status_from_classic(self.current_row.status) \ - == Submission.ERROR: - self.submission.status = Submission.ERROR - - self._inject_primary_if_changed() - self._inject_secondaries_if_changed() - self._inject_metadata_if_changed() - self._inject_jref_if_changed() - - if self.current_row.must_process == 0: - self._inject(ConfirmSourceProcessed) - - if self.current_row.is_announced(): - self._inject(Announce, arxiv_id=self.arxiv_id) - elif self.current_row.is_jref(): - self._inject_jref_if_changed() - elif self.current_row.is_withdrawal(): - self._inject_request_if_changed(WithdrawalRequest) - elif self.current_row.is_crosslist(): - self._inject_request_if_changed(CrossListClassificationRequest) - - def _inject_primary_if_changed(self) -> None: - """Inject primary classification event if a change has occurred.""" - assert self.current_row is not None - primary = self.current_row.primary_classification - if primary and self.submission is not None: - if primary.category != self.submission.primary_category: - self._inject(Reclassify, category=primary.category) - - def _inject_secondaries_if_changed(self) -> None: - """Inject secondary classification events if a change has occurred.""" - assert self.current_row is not None - # Add any missing secondaries. - for dbc in self.current_row.categories: - if (self.submission is not None - and dbc.category not in self.submission.secondary_categories - and not dbc.is_primary): - - self._inject(AddSecondaryClassification, - category=taxonomy.Category(dbc.category)) - - def _inject_metadata_if_changed(self) -> None: - assert self.submission is not None and self.current_row is not None - row = self.current_row # For readability, below. - if self.submission.metadata.title != row.title: - self._inject(SetTitle, title=row.title) - if self.submission.metadata.abstract != row.abstract: - self._inject(SetAbstract, abstract=row.abstract) - if self.submission.metadata.comments != row.comments: - self._inject(SetComments, comments=row.comments) - if self.submission.metadata.msc_class != row.msc_class: - self._inject(SetMSCClassification, msc_class=row.msc_class) - if self.submission.metadata.acm_class != row.acm_class: - self._inject(SetACMClassification, acm_class=row.acm_class) - if self.submission.metadata.authors_display != row.authors: - self._inject(SetAuthors, authors_display=row.authors) - - def _inject_jref_if_changed(self) -> None: - assert self.submission is not None and self.current_row is not None - row = self.current_row # For readability, below. - if self.submission.metadata.doi != self.current_row.doi: - self._inject(SetDOI, doi=row.doi) - if self.submission.metadata.journal_ref != row.journal_ref: - self._inject(SetJournalReference, journal_ref=row.journal_ref) - if self.submission.metadata.report_num != row.report_num: - self._inject(SetReportNumber, report_num=row.report_num) - - def _inject_request_if_changed(self, req_type: Type[UserRequest]) -> None: - """ - Update a request on the submission, if status changed. - - We will assume that the request itself originated in the NG system, - so we will NOT create a new request. - """ - assert self.submission is not None and self.current_row is not None - request_id = req_type.generate_request_id(self.submission, - self.requests[req_type]) - if self.current_row.is_announced(): - self._inject(ApplyRequest, request_id=request_id) - elif self.current_row.is_deleted(): - self._inject(CancelRequest, request_id=request_id) - elif self.current_row.is_rejected(): - self._inject(RejectRequest, request_id=request_id) - - def _inject(self, event_type: Type[Event], **data: Any) -> None: - assert self.submission is not None and self.current_row is not None - created = self.current_row.get_updated() - logger.debug('inject %s', event_type.NAME) - event = event_type(creator=SYSTEM, # type: ignore - created=created, # Mypy has a hard time with these - committed=True, # dataclass params. - submission_id=self.submission_id, - **data) - self._apply(event) - - def _apply(self, event: Event) -> None: - self.submission = event.apply(self.submission) - self.applied_events.append(event) - - def _backport_event(self, event: Event) -> None: - assert self.submission is not None - self.submission.versions[-1] = \ - event.apply(self.submission.versions[-1]) - - def get_submission_state(self) -> Tuple[Submission, List[Event]]: - """ - Get the current state of the :class:`Submission`. - - This is effectively memoized. - - Returns - ------- - :class:`.domain.submission.Submission` - The most recent state of the submission given the provided events - and database rows. - list - Items are :class:`.Event` instances applied to generate the - returned state. This may include events inferred and interpolated - from the classic database, not passed in the original set of - events. - - """ - for event in self.events: - # As we go, look for moments where a new row in the legacy - # submission table was created. - if self._current_row_preceeds_event(event) \ - or self._should_advance_to_next_row(event): - # If we find one, patch the domain submission from the - # preceding row, and load the next row. We want to do this - # before projecting the event, since we are inferring that the - # event occurred after a change was made via the legacy system. - if self._can_inject_from_current_row(): - self._inject_from_current_row() - - if self._should_advance_to_next_row(event): - self._advance_to_next_row() - - self._apply(event) # Now project the event. - - # Backport JREFs to the announced version to which they apply. - if self._should_backport(event): - self._backport_event(event) - - # Finally, patch the submission with any remaining changes that may - # have occurred via the legacy system. - while self.current_row is not None: - if self._can_inject_from_current_row(): - self._inject_from_current_row() - self._advance_to_next_row() - - assert self.submission is not None - logger.debug('done; submission in state %s with %i events', - self.submission.status, len(self.applied_events)) - return self.submission, self.applied_events diff --git a/src/arxiv/submission/services/classic/load.py b/src/arxiv/submission/services/classic/load.py deleted file mode 100644 index 7df707b..0000000 --- a/src/arxiv/submission/services/classic/load.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Supports loading :class:`.Submission` directly from classic data.""" - -import copy -from itertools import groupby -from operator import attrgetter -from typing import List, Optional, Iterable, Dict - -from arxiv.base import logging -from arxiv.license import LICENSES - -from ... import domain -from . import models -from .patch import patch_withdrawal, patch_jref, patch_cross, patch_hold - -logger = logging.getLogger(__name__) -logger.propagate = False - - -def load(rows: Iterable[models.Submission]) -> Optional[domain.Submission]: - """ - Load a submission entirely from its classic database rows. - - Parameters - ---------- - rows : list - Items are :class:`.models.Submission` rows loaded from the classic - database belonging to a single arXiv e-print/submission group. - - Returns - ------- - :class:`.domain.Submission` or ``None`` - Aggregated submission object (with ``.versions``). If there is no - representation (e.g. all rows are deleted), returns ``None``. - - """ - versions: List[domain.Submission] = [] - submission_id: Optional[int] = None - - # We want to work within versions, and (secondarily) in order of creation - # time. - rows = sorted(rows, key=lambda o: o.version) - logger.debug('Load from rows %s', [r.submission_id for r in rows]) - for version, version_rows in groupby(rows, key=attrgetter('version')): - # Creation time isn't all that precise in the classic database, so - # we'll use submission ID instead. - these_version_rows = sorted([v for v in version_rows], - key=lambda o: o.submission_id) - logger.debug('Version %s: %s', version, version_rows) - # We use the original ID to track the entire lifecycle of the - # submission in NG. - if version == 1: - submission_id = these_version_rows[0].submission_id - logger.debug('Submission ID: %s', submission_id) - - # Find the creation row. There may be some false starts that have been - # deleted, so we need to advance to the first non-deleted 'new' or - # 'replacement' row. - version_submission: Optional[domain.Submission] = None - while version_submission is None: - try: - row = these_version_rows.pop(0) - except IndexError: - break - if row.is_new_version() and \ - (row.type == row.NEW_SUBMISSION or not row.is_deleted()): - # Get the initial state of the version. - version_submission = to_submission(row, submission_id) - logger.debug('Got initial state: %s', version_submission) - - if version_submission is None: - logger.debug('Nothing to work with for this version') - continue - - # If this is not the first version, carry forward any requests. - if len(versions) > 0: - logger.debug('Bring user_requests forward from last version') - version_submission.user_requests.update(versions[-1].user_requests) - - for row in these_version_rows: # Remaining rows, since we popped the others. - # We are treating JREF submissions as though there is no approval - # process; so we can just ignore deleted JREF rows. - if row.is_jref() and not row.is_deleted(): - # This should update doi, journal_ref, report_num. - version_submission = patch_jref(version_submission, row) - # For withdrawals and cross-lists, we want to get data from - # deleted rows since we keep track of all requests in the NG - # submission. - elif row.is_withdrawal(): - # This should update the reason_for_withdrawal (if applied), - # and add a WithdrawalRequest to user_requests. - version_submission = patch_withdrawal(version_submission, row) - elif row.is_crosslist(): - # This should update the secondary classifications (if applied) - # and add a CrossListClassificationRequest to user_requests. - version_submission = patch_cross(version_submission, row) - - # We want hold information represented as a Hold on the submission - # object, not just the status. - if version_submission.is_on_hold: - version_submission = patch_hold(version_submission, row) - versions.append(version_submission) - - if not versions: - return None - submission = copy.deepcopy(versions[-1]) - submission.versions = [ver for ver in versions if ver and ver.is_announced] - return submission - - -def to_submission(row: models.Submission, - submission_id: Optional[int] = None) -> domain.Submission: - """ - Generate a representation of submission state from a DB instance. - - Parameters - ---------- - row : :class:`.models.Submission` - Database row representing a :class:`.domain.submission.Submission`. - submission_id : int or None - If provided the database value is overridden when setting - :attr:`domain.Submission.submission_id`. - - Returns - ------- - :class:`.domain.submission.Submission` - - """ - status = status_from_classic(row.status) - primary = row.primary_classification - if row.submitter is None: - submitter = domain.User(native_id=row.submitter_id, - email=row.submitter_email) - else: - submitter = row.get_submitter() - if submission_id is None: - submission_id = row.submission_id - - license: Optional[domain.License] = None - if row.license: - label = LICENSES[row.license]['label'] - license = domain.License(uri=row.license, name=label) - - primary_clsn: Optional[domain.Classification] = None - if primary and primary.category: - _category = domain.Category(primary.category) - primary_clsn = domain.Classification(category=_category) - secondary_clsn = [ - domain.Classification(category=domain.Category(db_cat.category)) - for db_cat in row.categories if not db_cat.is_primary - ] - - content: Optional[domain.SubmissionContent] = None - if row.package: - if row.package.startswith('fm://'): - identifier, checksum = row.package.split('://', 1)[1].split('@', 1) - else: - identifier = row.package - checksum = "" - source_format = domain.SubmissionContent.Format(row.source_format) - content = domain.SubmissionContent(identifier=identifier, - compressed_size=0, - uncompressed_size=row.source_size, - checksum=checksum, - source_format=source_format) - - assert status is not None - submission = domain.Submission( - submission_id=submission_id, - creator=submitter, - owner=submitter, - status=status, - created=row.get_created(), - updated=row.get_updated(), - source_content=content, - submitter_is_author=bool(row.is_author), - submitter_accepts_policy=bool(row.agree_policy), - submitter_contact_verified=bool(row.userinfo), - is_source_processed=not bool(row.must_process), - submitter_confirmed_preview=bool(row.viewed), - metadata=domain.SubmissionMetadata(title=row.title, - abstract=row.abstract, - comments=row.comments, - report_num=row.report_num, - doi=row.doi, - msc_class=row.msc_class, - acm_class=row.acm_class, - journal_ref=row.journal_ref), - license=license, - primary_classification=primary_clsn, - secondary_classification=secondary_clsn, - arxiv_id=row.doc_paper_id, - version=row.version - ) - if row.sticky_status == row.ON_HOLD or row.status == row.ON_HOLD: - submission = patch_hold(submission, row) - elif row.is_withdrawal(): - submission = patch_withdrawal(submission, row) - elif row.is_crosslist(): - submission = patch_cross(submission, row) - return submission - - -def status_from_classic(classic_status: int) -> Optional[str]: - """Map classic status codes to domain submission status.""" - return STATUS_MAP.get(classic_status) - - -# Map classic status to Submission domain status. -STATUS_MAP: Dict[int, str] = { - models.Submission.NOT_SUBMITTED: domain.Submission.WORKING, - models.Submission.SUBMITTED: domain.Submission.SUBMITTED, - models.Submission.ON_HOLD: domain.Submission.SUBMITTED, - models.Submission.NEXT_PUBLISH_DAY: domain.Submission.SCHEDULED, - models.Submission.PROCESSING: domain.Submission.SCHEDULED, - models.Submission.PROCESSING_SUBMISSION: domain.Submission.SCHEDULED, - models.Submission.NEEDS_EMAIL: domain.Submission.SCHEDULED, - models.Submission.ANNOUNCED: domain.Submission.ANNOUNCED, - models.Submission.DELETED_ANNOUNCED: domain.Submission.ANNOUNCED, - models.Submission.USER_DELETED: domain.Submission.DELETED, - models.Submission.DELETED_EXPIRED: domain.Submission.DELETED, - models.Submission.DELETED_ON_HOLD: domain.Submission.DELETED, - models.Submission.DELETED_PROCESSING: domain.Submission.DELETED, - models.Submission.DELETED_REMOVED: domain.Submission.DELETED, - models.Submission.DELETED_USER_EXPIRED: domain.Submission.DELETED, - models.Submission.ERROR_STATE: domain.Submission.ERROR -} diff --git a/src/arxiv/submission/services/classic/log.py b/src/arxiv/submission/services/classic/log.py deleted file mode 100644 index c467b7e..0000000 --- a/src/arxiv/submission/services/classic/log.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Interface to the classic admin log.""" - -from typing import Optional, Iterable, Dict, Callable, List - -from . import models, util -from ...domain.event import Event, UnFinalizeSubmission, AcceptProposal, \ - AddSecondaryClassification, AddMetadataFlag, AddContentFlag, \ - AddClassifierResults -from ...domain.annotation import ClassifierResults -from ...domain.submission import Submission -from ...domain.agent import Agent, System -from ...domain.flag import MetadataFlag, ContentFlag - - -def log_unfinalize(event: Event, before: Optional[Submission], - after: Submission) -> None: - """Create a log entry when a user pulls their submission for changes.""" - assert isinstance(event, UnFinalizeSubmission) - admin_log(event.creator.username, "unfinalize", - "user has pulled submission for editing", - username=event.creator.username, - hostname=event.creator.hostname, - submission_id=after.submission_id, - paper_id=after.arxiv_id) - - -def log_accept_system_cross(event: Event, before: Optional[Submission], - after: Submission) -> None: - """Create a log entry when a system cross is accepted.""" - assert isinstance(event, AcceptProposal) and event.proposal_id is not None - proposal = after.proposals[event.proposal_id] - if type(event.creator) is System: - if proposal.proposed_event_type is AddSecondaryClassification: - category = proposal.proposed_event_data["category"] - admin_log(event.creator.username, "admin comment", - f"Added {category} as secondary: {event.comment}", - username="system", - submission_id=after.submission_id, - paper_id=after.arxiv_id) - - -def log_stopwords(event: Event, before: Optional[Submission], - after: Submission) -> None: - """Create a log entry when there is a problem with stopword content.""" - assert isinstance(event, AddContentFlag) - if event.flag_type is ContentFlag.FlagType.LOW_STOP: - admin_log(event.creator.username, - "admin comment", - event.comment if event.comment is not None else "", - username="system", - submission_id=after.submission_id, - paper_id=after.arxiv_id) - - -def log_classifier_failed(event: Event, before: Optional[Submission], - after: Submission) -> None: - """Create a log entry when the classifier returns no suggestions.""" - assert isinstance(event, AddClassifierResults) - if not event.results: - admin_log(event.creator.username, "admin comment", - "Classifier failed to return results for submission", - username="system", - submission_id=after.submission_id, - paper_id=after.arxiv_id) - - -Callback = Callable[[Event, Optional[Submission], Submission], None] - -ON_EVENT: Dict[type, List[Callback]] = { - UnFinalizeSubmission: [log_unfinalize], - AcceptProposal: [log_accept_system_cross], - AddContentFlag: [log_stopwords] -} -"""Logging functions to call when an event is comitted.""" - - -def handle(event: Event, before: Optional[Submission], - after: Submission) -> None: - """ - Generate an admin log entry for an event that is being committed. - - Looks for a logging function in :const:`.ON_EVENT` and, if found, calls it - with the passed parameters. - - Parameters - ---------- - event : :class:`event.Event` - The event being committed. - before : :class:`.domain.submission.Submission` - State of the submission before the event. - after : :class:`.domain.submission.Submission` - State of the submission after the event. - - """ - if type(event) in ON_EVENT: - for callback in ON_EVENT[type(event)]: - callback(event, before, after) - - -def admin_log(program: str, command: str, text: str, notify: bool = False, - username: Optional[str] = None, - hostname: Optional[str] = None, - submission_id: Optional[int] = None, - paper_id: Optional[str] = None, - document_id: Optional[int] = None) -> models.AdminLogEntry: - """ - Add an entry to the admin log. - - Parameters - ---------- - program : str - Name of the application generating the log entry. - command : str - Name of the command generating the log entry. - text : str - Content of the admin log entry. - notify : bool - username : str - hostname : str - Hostname or IP address of the client. - submission_id : int - paper_id : str - document_id : int - - """ - if paper_id is None and submission_id is not None: - paper_id = f'submit/{submission_id}' - with util.transaction() as session: - entry = models.AdminLogEntry( - paper_id=paper_id, - username=username, - host=hostname, - program=program, - command=command, - logtext=text, - document_id=document_id, - submission_id=submission_id, - notify=notify - ) - session.add(entry) - return entry diff --git a/src/arxiv/submission/services/classic/models.py b/src/arxiv/submission/services/classic/models.py deleted file mode 100644 index e8b462d..0000000 --- a/src/arxiv/submission/services/classic/models.py +++ /dev/null @@ -1,909 +0,0 @@ -"""SQLAlchemy ORM classes for the classic database.""" - -import json -from typing import Optional, List, Any -from datetime import datetime -from pytz import UTC -from sqlalchemy import Column, Date, DateTime, Enum, ForeignKey, Text, text, \ - ForeignKeyConstraint, Index, Integer, SmallInteger, String, Table -from sqlalchemy.orm import relationship, joinedload, backref -from sqlalchemy.ext.declarative import declarative_base - -from arxiv.base import logging -from arxiv.license import LICENSES -from arxiv import taxonomy - -from ... import domain -from .util import transaction - -Base = declarative_base() - -logger = logging.getLogger(__name__) - - -class Submission(Base): # type: ignore - """Represents an arXiv submission.""" - - __tablename__ = 'arXiv_submissions' - - # Pre-moderation stages; these are tied to the classic submission UI. - NEW = 0 - STARTED = 1 - FILES_ADDED = 2 - PROCESSED = 3 - METADATA_ADDED = 4 - SUBMITTED = 5 - STAGES = [NEW, STARTED, FILES_ADDED, PROCESSED, METADATA_ADDED, SUBMITTED] - - # Submission status; this describes where the submission is in the - # publication workflow. - NOT_SUBMITTED = 0 # Working. - SUBMITTED = 1 # Enqueued for moderation, to be scheduled. - ON_HOLD = 2 - UNUSED = 3 - NEXT_PUBLISH_DAY = 4 - """Scheduled for the next publication cycle.""" - PROCESSING = 5 - """Scheduled for today.""" - NEEDS_EMAIL = 6 - """Announced, not yet announced.""" - - ANNOUNCED = 7 - DELETED_ANNOUNCED = 27 - """Announced and files expired.""" - - PROCESSING_SUBMISSION = 8 - REMOVED = 9 # This is "rejected". - - USER_DELETED = 10 - ERROR_STATE = 19 - """There was a problem validating the submission during publication.""" - - DELETED_EXPIRED = 20 - """Was working but expired.""" - DELETED_ON_HOLD = 22 - DELETED_PROCESSING = 25 - - DELETED_REMOVED = 29 - DELETED_USER_EXPIRED = 30 - """User deleted and files expired.""" - - DELETED = ( - USER_DELETED, DELETED_ON_HOLD, DELETED_PROCESSING, - DELETED_REMOVED, DELETED_USER_EXPIRED, DELETED_EXPIRED - ) - - NEW_SUBMISSION = 'new' - REPLACEMENT = 'rep' - JOURNAL_REFERENCE = 'jref' - WITHDRAWAL = 'wdr' - CROSS_LIST = 'cross' - WITHDRAWN_FORMAT = 'withdrawn' - - submission_id = Column(Integer, primary_key=True) - - type = Column(String(8), index=True) - """Submission type (e.g. ``new``, ``jref``, ``cross``).""" - - document_id = Column( - ForeignKey('arXiv_documents.document_id', - ondelete='CASCADE', - onupdate='CASCADE'), - index=True - ) - doc_paper_id = Column(String(20), index=True) - - sword_id = Column(ForeignKey('arXiv_tracking.sword_id'), index=True) - userinfo = Column(Integer, server_default=text("'0'")) - is_author = Column(Integer, nullable=False, server_default=text("'0'")) - agree_policy = Column(Integer, server_default=text("'0'")) - viewed = Column(Integer, server_default=text("'0'")) - stage = Column(Integer, server_default=text("'0'")) - submitter_id = Column( - ForeignKey('tapir_users.user_id', ondelete='CASCADE', - onupdate='CASCADE'), - index=True - ) - submitter_name = Column(String(64)) - submitter_email = Column(String(64)) - created = Column(DateTime, default=lambda: datetime.now(UTC)) - updated = Column(DateTime, onupdate=lambda: datetime.now(UTC)) - status = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - sticky_status = Column(Integer) - """ - If the submission goes out of queue (e.g. submitter makes changes), - this status should be applied when the submission is re-finalized - (goes back into queue, comes out of working status). - """ - - must_process = Column(Integer, server_default=text("'1'")) - submit_time = Column(DateTime) - release_time = Column(DateTime) - - source_size = Column(Integer, server_default=text("'0'")) - source_format = Column(String(12)) - """Submission content type (e.g. ``pdf``, ``tex``, ``pdftex``).""" - source_flags = Column(String(12)) - - allow_tex_produced = Column(Integer, server_default=text("'0'")) - """Whether to allow a TeX-produced PDF.""" - - package = Column(String(255), nullable=False, server_default=text("''")) - """Path (on disk) to the submission package (tarball, PDF).""" - - is_oversize = Column(Integer, server_default=text("'0'")) - - has_pilot_data = Column(Integer) - is_withdrawn = Column(Integer, nullable=False, server_default=text("'0'")) - title = Column(Text) - authors = Column(Text) - comments = Column(Text) - proxy = Column(String(255)) - report_num = Column(Text) - msc_class = Column(String(255)) - acm_class = Column(String(255)) - journal_ref = Column(Text) - doi = Column(String(255)) - abstract = Column(Text) - license = Column(ForeignKey('arXiv_licenses.name', onupdate='CASCADE'), - index=True) - version = Column(Integer, nullable=False, server_default=text("'1'")) - - is_ok = Column(Integer, index=True) - - admin_ok = Column(Integer) - """Used by administrators for reporting/bookkeeping.""" - - remote_addr = Column(String(16), nullable=False, server_default=text("''")) - remote_host = Column(String(255), nullable=False, - server_default=text("''")) - rt_ticket_id = Column(Integer, index=True) - auto_hold = Column(Integer, server_default=text("'0'")) - """Should be placed on hold when submission comes out of working status.""" - - document = relationship('Document') - arXiv_license = relationship('License') - submitter = relationship('User') - sword = relationship('Tracking') - categories = relationship('SubmissionCategory', - back_populates='submission', lazy='joined', - cascade="all, delete-orphan") - - def get_submitter(self) -> domain.User: - """Generate a :class:`.User` representing the submitter.""" - extra = {} - if self.submitter: - extra.update(dict(forename=self.submitter.first_name, - surname=self.submitter.last_name, - suffix=self.submitter.suffix_name)) - return domain.User(native_id=self.submitter_id, - email=self.submitter_email, **extra) - - - WDR_DELIMETER = '. Withdrawn: ' - - def get_withdrawal_reason(self) -> Optional[str]: - """Extract the withdrawal reason from the comments field.""" - if Submission.WDR_DELIMETER not in self.comments: - return None - return str(self.comments.split(Submission.WDR_DELIMETER, 1)[1]) - - def update_withdrawal(self, submission: domain.Submission, reason: str, - paper_id: str, version: int, - created: datetime) -> None: - """Update withdrawal request information in the database.""" - self.update_from_submission(submission) - self.created = created - self.updated = created - self.doc_paper_id = paper_id - self.status = Submission.PROCESSING_SUBMISSION - reason = f"{Submission.WDR_DELIMETER}{reason}" - self.comments = self.comments.rstrip('. ') + reason - - def update_cross(self, submission: domain.Submission, - categories: List[str], paper_id: str, version: int, - created: datetime) -> None: - """Update cross-list request information in the database.""" - self.update_from_submission(submission) - self.created = created - self.updated = created - self.doc_paper_id = paper_id - self.status = Submission.PROCESSING_SUBMISSION - for category in categories: - self.categories.append( - SubmissionCategory(submission_id=self.submission_id, - category=category, is_primary=0)) - - def update_from_submission(self, submission: domain.Submission) -> None: - """Update this database object from a :class:`.domain.submission.Submission`.""" - if self.is_announced(): # Avoid doing anything. to be safe. - return - - self.submitter_id = submission.creator.native_id - self.submitter_name = submission.creator.name - self.submitter_email = submission.creator.email - self.is_author = 1 if submission.submitter_is_author else 0 - self.agree_policy = 1 if submission.submitter_accepts_policy else 0 - self.userinfo = 1 if submission.submitter_contact_verified else 0 - self.viewed = 1 if submission.submitter_confirmed_preview else 0 - self.updated = submission.updated - self.title = submission.metadata.title - self.abstract = submission.metadata.abstract - self.authors = submission.metadata.authors_display - self.comments = submission.metadata.comments - self.report_num = submission.metadata.report_num - self.doi = submission.metadata.doi - self.msc_class = submission.metadata.msc_class - self.acm_class = submission.metadata.acm_class - self.journal_ref = submission.metadata.journal_ref - - self.version = submission.version # Numeric version. - self.doc_paper_id = submission.arxiv_id # arXiv canonical ID. - - # The document ID is a legacy concept, and not replicated in the NG - # data model. So we need to grab it from the arXiv_documents table - # using the doc_paper_id. - if self.doc_paper_id and not self.document_id: - doc = _load_document(paper_id=self.doc_paper_id) - self.document_id = doc.document_id - - if submission.license: - self.license = submission.license.uri - - if submission.source_content is not None: - self.source_size = submission.source_content.uncompressed_size - if submission.source_content.source_format is not None: - self.source_format = \ - submission.source_content.source_format.value - else: - self.source_format = None - self.package = (f'fm://{submission.source_content.identifier}' - f'@{submission.source_content.checksum}') - - if submission.is_source_processed: - self.must_process = 0 - else: - self.must_process = 1 - - # Not submitted -> Submitted. - if submission.is_finalized \ - and self.status in [Submission.NOT_SUBMITTED, None]: - self.status = Submission.SUBMITTED - self.submit_time = submission.updated - # Delete. - elif submission.is_deleted: - self.status = Submission.USER_DELETED - elif submission.is_on_hold: - self.status = Submission.ON_HOLD - # Unsubmit. - elif self.status is None or self.status <= Submission.ON_HOLD: - if not submission.is_finalized: - self.status = Submission.NOT_SUBMITTED - - if submission.primary_classification: - self._update_primary(submission) - self._update_secondaries(submission) - self._update_submitter(submission) - - # We only want to set the creation datetime on the initial row. - if self.version == 1 and self.type == Submission.NEW_SUBMISSION: - self.created = submission.created - - @property - def primary_classification(self) -> Optional['Category']: - """Get the primary classification for this submission.""" - categories = [ - db_cat for db_cat in self.categories if db_cat.is_primary == 1 - ] - try: - cat: Category = categories[0] - except IndexError: - return None - return cat - - def get_arxiv_id(self) -> Optional[str]: - """Get the arXiv identifier for this submission.""" - if not self.document: - return None - paper_id: Optional[str] = self.document.paper_id - return paper_id - - def get_created(self) -> datetime: - """Get the UTC-localized creation datetime.""" - dt: datetime = self.created.replace(tzinfo=UTC) - return dt - - def get_updated(self) -> datetime: - """Get the UTC-localized updated datetime.""" - dt: datetime = self.updated.replace(tzinfo=UTC) - return dt - - def is_working(self) -> bool: - return bool(self.status == self.NOT_SUBMITTED) - - def is_announced(self) -> bool: - return bool(self.status in [self.ANNOUNCED, self.DELETED_ANNOUNCED]) - - def is_active(self) -> bool: - return bool(not self.is_announced() and not self.is_deleted()) - - def is_rejected(self) -> bool: - return bool(self.status == self.REMOVED) - - def is_finalized(self) -> bool: - return bool(self.status > self.WORKING and not self.is_deleted()) - - def is_deleted(self) -> bool: - return bool(self.status in self.DELETED) - - def is_on_hold(self) -> bool: - return bool(self.status == self.ON_HOLD) - - def is_new_version(self) -> bool: - """Indicate whether this row represents a new version.""" - return bool(self.type in [self.NEW_SUBMISSION, self.REPLACEMENT]) - - def is_withdrawal(self) -> bool: - return bool(self.type == self.WITHDRAWAL) - - def is_crosslist(self) -> bool: - return bool(self.type == self.CROSS_LIST) - - def is_jref(self) -> bool: - return bool(self.type == self.JOURNAL_REFERENCE) - - @property - def secondary_categories(self) -> List[str]: - """Category names from this submission's secondary classifications.""" - return [c.category for c in self.categories if c.is_primary == 0] - - def _update_submitter(self, submission: domain.Submission) -> None: - """Update submitter information on this row.""" - self.submitter_id = submission.creator.native_id - self.submitter_email = submission.creator.email - - def _update_primary(self, submission: domain.Submission) -> None: - """Update primary classification on this row.""" - assert submission.primary_classification is not None - primary_category = submission.primary_classification.category - cur_primary = self.primary_classification - - if cur_primary and cur_primary.category != primary_category: - self.categories.remove(cur_primary) - self.categories.append( - SubmissionCategory(submission_id=self.submission_id, - category=primary_category) - ) - elif cur_primary is None and primary_category: - self.categories.append( - SubmissionCategory( - submission_id=self.submission_id, - category=primary_category, - is_primary=1 - ) - ) - - def _update_secondaries(self, submission: domain.Submission) -> None: - """Update secondary classifications on this row.""" - # Remove any categories that have been removed from the Submission. - for db_cat in self.categories: - if db_cat.is_primary == 1: - continue - if db_cat.category not in submission.secondary_categories: - self.categories.remove(db_cat) - - # Add any new secondaries - for cat in submission.secondary_classification: - if cat.category not in self.secondary_categories: - self.categories.append( - SubmissionCategory( - submission_id=self.submission_id, - category=cat.category, - is_primary=0 - ) - ) - - -class License(Base): # type: ignore - """Licenses available for submissions.""" - - __tablename__ = 'arXiv_licenses' - - name = Column(String(255), primary_key=True) - """This is the URI of the license.""" - - label = Column(String(255)) - """Display label for the license.""" - - active = Column(Integer, server_default=text("'1'")) - """Only offer licenses with active=1.""" - - note = Column(String(255)) - sequence = Column(Integer) - - -class CategoryDef(Base): # type: ignore - """Classification categories available for submissions.""" - - __tablename__ = 'arXiv_category_def' - - category = Column(String(32), primary_key=True) - name = Column(String(255)) - active = Column(Integer, server_default=text("'1'")) - - -class SubmissionCategory(Base): # type: ignore - """Classification relation for submissions.""" - - __tablename__ = 'arXiv_submission_category' - - submission_id = Column( - ForeignKey('arXiv_submissions.submission_id', - ondelete='CASCADE', onupdate='CASCADE'), - primary_key=True, - nullable=False, - index=True - ) - category = Column( - ForeignKey('arXiv_category_def.category'), - primary_key=True, - nullable=False, - index=True, - server_default=text("''") - ) - is_primary = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - is_published = Column(Integer, index=True, server_default=text("'0'")) - - # category_def = relationship('CategoryDef') - submission = relationship('Submission', back_populates='categories') - - -class Document(Base): # type: ignore - """ - Represents an announced arXiv paper. - - This is here so that we can look up the arXiv ID after a submission is - announced. - """ - - __tablename__ = 'arXiv_documents' - - document_id = Column(Integer, primary_key=True) - paper_id = Column(String(20), nullable=False, unique=True, - server_default=text("''")) - title = Column(String(255), nullable=False, index=True, - server_default=text("''")) - authors = Column(Text) - """Canonical author string.""" - - dated = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - - primary_subject_class = Column(String(16)) - - created = Column(DateTime) - - submitter_email = Column(String(64), nullable=False, index=True, - server_default=text("''")) - submitter_id = Column(ForeignKey('tapir_users.user_id'), index=True) - submitter = relationship('User') - - @property - def dated_datetime(self) -> datetime: - """Return the created time as a datetime.""" - return datetime.utcfromtimestamp(self.dated).replace(tzinfo=UTC) - - -class DocumentCategory(Base): # type: ignore - """Relation between announced arXiv papers and their classifications.""" - - __tablename__ = 'arXiv_document_category' - - document_id = Column( - ForeignKey('arXiv_documents.document_id', ondelete='CASCADE'), - primary_key=True, - nullable=False, - index=True, - server_default=text("'0'") - ) - category = Column( - ForeignKey('arXiv_category_def.category'), - primary_key=True, - nullable=False, - index=True - ) - """E.g. cs.CG, cond-mat.dis-nn, etc.""" - is_primary = Column(Integer, nullable=False, server_default=text("'0'")) - - category_def = relationship('CategoryDef') - document = relationship('Document') - - -class User(Base): # type: ignore - """Represents an arXiv user.""" - - __tablename__ = 'tapir_users' - - user_id = Column(Integer, primary_key=True) - first_name = Column(String(50), index=True) - last_name = Column(String(50), index=True) - suffix_name = Column(String(50)) - share_first_name = Column(Integer, nullable=False, - server_default=text("'1'")) - share_last_name = Column(Integer, nullable=False, - server_default=text("'1'")) - email = Column(String(255), nullable=False, unique=True, - server_default=text("''")) - share_email = Column(Integer, nullable=False, server_default=text("'8'")) - email_bouncing = Column(Integer, nullable=False, - server_default=text("'0'")) - policy_class = Column(ForeignKey('tapir_policy_classes.class_id'), - nullable=False, index=True, - server_default=text("'0'")) - """ - +----------+---------------+ - | class_id | name | - +----------+---------------+ - | 1 | Administrator | - | 2 | Public user | - | 3 | Legacy user | - +----------+---------------+ - """ - - joined_date = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - joined_ip_num = Column(String(16), index=True) - joined_remote_host = Column(String(255), nullable=False, - server_default=text("''")) - flag_internal = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - flag_edit_users = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - flag_edit_system = Column(Integer, nullable=False, - server_default=text("'0'")) - flag_email_verified = Column(Integer, nullable=False, - server_default=text("'0'")) - flag_approved = Column(Integer, nullable=False, index=True, - server_default=text("'1'")) - flag_deleted = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - flag_banned = Column(Integer, nullable=False, index=True, - server_default=text("'0'")) - flag_wants_email = Column(Integer, nullable=False, - server_default=text("'0'")) - flag_html_email = Column(Integer, nullable=False, - server_default=text("'0'")) - tracking_cookie = Column(String(255), nullable=False, index=True, - server_default=text("''")) - flag_allow_tex_produced = Column(Integer, nullable=False, - server_default=text("'0'")) - - tapir_policy_class = relationship('PolicyClass') - - def to_user(self) -> domain.agent.User: - return domain.agent.User( - self.user_id, - self.email, - username=self.username, - forename=self.first_name, - surname=self.last_name, - suffix=self.suffix_name - ) - - -class Username(Base): # type: ignore - """ - Users' usernames (because why not have a separate table). - - +--------------+------------------+------+-----+---------+----------------+ - | Field | Type | Null | Key | Default | Extra | - +--------------+------------------+------+-----+---------+----------------+ - | nick_id | int(10) unsigned | NO | PRI | NULL | autoincrement | - | nickname | varchar(20) | NO | UNI | | | - | user_id | int(4) unsigned | NO | MUL | 0 | | - | user_seq | int(1) unsigned | NO | | 0 | | - | flag_valid | int(1) unsigned | NO | MUL | 0 | | - | role | int(10) unsigned | NO | MUL | 0 | | - | policy | int(10) unsigned | NO | MUL | 0 | | - | flag_primary | int(1) unsigned | NO | | 0 | | - +--------------+------------------+------+-----+---------+----------------+ - """ - - __tablename__ = 'tapir_nicknames' - - nick_id = Column(Integer, primary_key=True) - nickname = Column(String(20), nullable=False, unique=True, index=True) - user_id = Column(ForeignKey('tapir_users.user_id'), nullable=False, - server_default=text("'0'")) - user = relationship('User') - user_seq = Column(Integer, nullable=False, server_default=text("'0'")) - flag_valid = Column(Integer, nullable=False, server_default=text("'0'")) - role = Column(Integer, nullable=False, server_default=text("'0'")) - policy = Column(Integer, nullable=False, server_default=text("'0'")) - flag_primary = Column(Integer, nullable=False, server_default=text("'0'")) - - user = relationship('User') - - -# TODO: what is this? -class PolicyClass(Base): # type: ignore - """Defines user roles in the system.""" - - __tablename__ = 'tapir_policy_classes' - - class_id = Column(SmallInteger, primary_key=True) - name = Column(String(64), nullable=False, server_default=text("''")) - description = Column(Text, nullable=False) - password_storage = Column(Integer, nullable=False, - server_default=text("'0'")) - recovery_policy = Column(Integer, nullable=False, - server_default=text("'0'")) - permanent_login = Column(Integer, nullable=False, - server_default=text("'0'")) - - -class Tracking(Base): # type: ignore - """Record of SWORD submissions.""" - - __tablename__ = 'arXiv_tracking' - - tracking_id = Column(Integer, primary_key=True) - sword_id = Column(Integer, nullable=False, unique=True, - server_default=text("'00000000'")) - paper_id = Column(String(32), nullable=False) - submission_errors = Column(Text) - timestamp = Column(DateTime, nullable=False, - server_default=text("CURRENT_TIMESTAMP")) - - -class ArchiveCategory(Base): # type: ignore - """Maps categories to the archives in which they reside.""" - - __tablename__ = 'arXiv_archive_category' - - archive_id = Column(String(16), primary_key=True, nullable=False, - server_default=text("''")) - category_id = Column(String(32), primary_key=True, nullable=False) - - -class ArchiveDef(Base): # type: ignore - """Defines the archives in the arXiv classification taxonomy.""" - - __tablename__ = 'arXiv_archive_def' - - archive = Column(String(16), primary_key=True, server_default=text("''")) - name = Column(String(255)) - - -class ArchiveGroup(Base): # type: ignore - """Maps archives to the groups in which they reside.""" - - __tablename__ = 'arXiv_archive_group' - - archive_id = Column(String(16), primary_key=True, nullable=False, - server_default=text("''")) - group_id = Column(String(16), primary_key=True, nullable=False, - server_default=text("''")) - - -class Archive(Base): # type: ignore - """Supplemental data about archives in the classification hierarchy.""" - - __tablename__ = 'arXiv_archives' - - archive_id = Column(String(16), primary_key=True, - server_default=text("''")) - in_group = Column(ForeignKey('arXiv_groups.group_id'), nullable=False, - index=True, server_default=text("''")) - archive_name = Column(String(255), nullable=False, - server_default=text("''")) - start_date = Column(String(4), nullable=False, server_default=text("''")) - end_date = Column(String(4), nullable=False, server_default=text("''")) - subdivided = Column(Integer, nullable=False, server_default=text("'0'")) - - arXiv_group = relationship('Group') - - -class GroupDef(Base): # type: ignore - """Defines the groups in the arXiv classification taxonomy.""" - - __tablename__ = 'arXiv_group_def' - - archive_group = Column(String(16), primary_key=True, - server_default=text("''")) - name = Column(String(255)) - - -class Group(Base): # type: ignore - """Supplemental data about groups in the classification hierarchy.""" - - __tablename__ = 'arXiv_groups' - - group_id = Column(String(16), primary_key=True, server_default=text("''")) - group_name = Column(String(255), nullable=False, server_default=text("''")) - start_year = Column(String(4), nullable=False, server_default=text("''")) - - -class EndorsementDomain(Base): # type: ignore - """Endorsement configurations.""" - - __tablename__ = 'arXiv_endorsement_domains' - - endorsement_domain = Column(String(32), primary_key=True, - server_default=text("''")) - endorse_all = Column(Enum('y', 'n'), nullable=False, - server_default=text("'n'")) - mods_endorse_all = Column(Enum('y', 'n'), nullable=False, - server_default=text("'n'")) - endorse_email = Column(Enum('y', 'n'), nullable=False, - server_default=text("'y'")) - papers_to_endorse = Column(SmallInteger, nullable=False, - server_default=text("'4'")) - - -class Category(Base): # type: ignore - """Supplemental data about arXiv categories, including endorsement.""" - - __tablename__ = 'arXiv_categories' - - arXiv_endorsement_domain = relationship('EndorsementDomain') - - archive = Column( - ForeignKey('arXiv_archives.archive_id'), - primary_key=True, - nullable=False, - server_default=text("''") - ) - """E.g. cond-mat, astro-ph, cs.""" - arXiv_archive = relationship('Archive') - - subject_class = Column(String(16), primary_key=True, nullable=False, - server_default=text("''")) - """E.g. AI, spr-con, str-el, CO, EP.""" - - definitive = Column(Integer, nullable=False, server_default=text("'0'")) - active = Column(Integer, nullable=False, server_default=text("'0'")) - """Only use rows where active == 1.""" - - category_name = Column(String(255)) - endorse_all = Column( - Enum('y', 'n', 'd'), - nullable=False, - server_default=text("'d'") - ) - endorse_email = Column( - Enum('y', 'n', 'd'), - nullable=False, - server_default=text("'d'") - ) - endorsement_domain = Column( - ForeignKey('arXiv_endorsement_domains.endorsement_domain'), - index=True - ) - """E.g. astro-ph, acc-phys, chem-ph, cs.""" - - papers_to_endorse = Column(SmallInteger, nullable=False, - server_default=text("'0'")) - - -class AdminLogEntry(Base): # type: ignore - """ - - +---------------+-----------------------+------+-----+-------------------+ - | Field | Type | Null | Key | Default | - +---------------+-----------------------+------+-----+-------------------+ - | id | int(11) | NO | PRI | NULL | - | logtime | varchar(24) | YES | | NULL | - | created | timestamp | NO | | CURRENT_TIMESTAMP | - | paper_id | varchar(20) | YES | MUL | NULL | - | username | varchar(20) | YES | | NULL | - | host | varchar(64) | YES | | NULL | - | program | varchar(20) | YES | | NULL | - | command | varchar(20) | YES | MUL | NULL | - | logtext | text | YES | | NULL | - | document_id | mediumint(8) unsigned | YES | | NULL | - | submission_id | int(11) | YES | MUL | NULL | - | notify | tinyint(1) | YES | | 0 | - +---------------+-----------------------+------+-----+-------------------+ - """ - - __tablename__ = 'arXiv_admin_log' - - id = Column(Integer, primary_key=True) - logtime = Column(String(24), nullable=True) - created = Column(DateTime, default=lambda: datetime.now(UTC)) - paper_id = Column(String(20), nullable=True) - username = Column(String(20), nullable=True) - host = Column(String(64), nullable=True) - program = Column(String(20), nullable=True) - command = Column(String(20), nullable=True) - logtext = Column(Text, nullable=True) - document_id = Column(Integer, nullable=True) - submission_id = Column(Integer, nullable=True) - notify = Column(Integer, nullable=True, default=0) - - -class CategoryProposal(Base): # type: ignore - """ - Represents a proposal to change the classification of a submission. - - +---------------------+-----------------+------+-----+---------+ - | Field | Type | Null | Key | Default | - +---------------------+-----------------+------+-----+---------+ - | proposal_id | int(11) | NO | PRI | NULL | - | submission_id | int(11) | NO | PRI | NULL | - | category | varchar(32) | NO | PRI | NULL | - | is_primary | tinyint(1) | NO | PRI | 0 | - | proposal_status | int(11) | YES | | 0 | - | user_id | int(4) unsigned | NO | MUL | NULL | - | updated | datetime | YES | | NULL | - | proposal_comment_id | int(11) | YES | MUL | NULL | - | response_comment_id | int(11) | YES | MUL | NULL | - +---------------------+-----------------+------+-----+---------+ - """ - - __tablename__ = 'arXiv_submission_category_proposal' - - UNRESOLVED = 0 - ACCEPTED_AS_PRIMARY = 1 - ACCEPTED_AS_SECONDARY = 2 - REJECTED = 3 - DOMAIN_STATUS = { - UNRESOLVED: domain.proposal.Proposal.Status.PENDING, - ACCEPTED_AS_PRIMARY: domain.proposal.Proposal.Status.ACCEPTED, - ACCEPTED_AS_SECONDARY: domain.proposal.Proposal.Status.ACCEPTED, - REJECTED: domain.proposal.Proposal.Status.REJECTED - } - - proposal_id = Column(Integer, primary_key=True) - submission_id = Column(ForeignKey('arXiv_submissions.submission_id')) - submission = relationship('Submission') - category = Column(String(32)) - is_primary = Column(Integer, server_default=text("'0'")) - proposal_status = Column(Integer, nullable=True, server_default=text("'0'")) - user_id = Column(ForeignKey('tapir_users.user_id')) - user = relationship("User") - updated = Column(DateTime, default=lambda: datetime.now(UTC)) - proposal_comment_id = Column(ForeignKey('arXiv_admin_log.id'), - nullable=True) - proposal_comment = relationship("AdminLogEntry", - foreign_keys=[proposal_comment_id]) - response_comment_id = Column(ForeignKey('arXiv_admin_log.id'), - nullable=True) - response_comment = relationship("AdminLogEntry", - foreign_keys=[response_comment_id]) - - def status_from_domain(self, proposal: domain.proposal.Proposal) -> int: - if proposal.status == domain.proposal.Proposal.Status.PENDING: - return self.UNRESOLVED - elif proposal.status == domain.proposal.Proposal.Status.REJECTED: - return self.REJECTED - elif proposal.status == domain.proposal.Proposal.Status.ACCEPTED: - if proposal.proposed_event_type \ - is domain.event.SetPrimaryClassification: - return self.ACCEPTED_AS_PRIMARY - else: - return self.ACCEPTED_AS_SECONDARY - raise RuntimeError(f'Could not determine status: {proposal.status}') - - - -def _load_document(paper_id: str) -> Document: - with transaction() as session: - document: Document = session.query(Document) \ - .filter(Document.paper_id == paper_id) \ - .one() - if document is None: - raise RuntimeError('No such document') - return document - - -def _get_user_by_username(username: str) -> User: - with transaction() as session: - u: User = session.query(Username) \ - .filter(Username.nickname == username) \ - .first() \ - .user - return u diff --git a/src/arxiv/submission/services/classic/patch.py b/src/arxiv/submission/services/classic/patch.py deleted file mode 100644 index 9cdf06b..0000000 --- a/src/arxiv/submission/services/classic/patch.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Methods for updating :class:`.Submission` with state outside event scope.""" - -from typing import List, Dict, Any, Type - -from ... import domain -from ...domain.submission import UserRequest -from . import models - - -def patch_hold(submission: domain.Submission, - row: models.Submission) -> domain.Submission: - """Patch hold-related data from this database row.""" - if not row.is_new_version(): - raise ValueError('Only applies to new and replacement rows') - - if row.status == row.ON_HOLD: - created = row.get_updated() - creator = domain.agent.System(__name__) - event_id = domain.Event.get_id(created, 'AddHold', creator) - hold = domain.Hold(event_id=event_id, creator=creator, - created=created, - hold_type=domain.Hold.Type.PATCH) - submission.holds[event_id] = hold - return submission - - -def patch_jref(submission: domain.Submission, - row: models.Submission) -> domain.Submission: - """ - Patch a :class:`.domain.submission.Submission` with JREF data outside the event scope. - - Parameters - ---------- - submission : :class:`.domain.submission.Submission` - The submission object to patch. - - Returns - ------- - :class:`.domain.submission.Submission` - The same submission that was passed; now patched with JREF data - outside the scope of the event model. - - """ - submission.metadata.doi = row.doi - submission.metadata.journal_ref = row.journal_ref - submission.metadata.report_num = row.report_num - return submission - - -# This should update the reason_for_withdrawal (if applied), -# and add a WithdrawalRequest to user_requests. -def patch_withdrawal(submission: domain.Submission, row: models.Submission, - request_number: int = -1) -> domain.Submission: - req_type = domain.WithdrawalRequest - data = {'reason_for_withdrawal': row.get_withdrawal_reason()} - return _patch_request(req_type, data, submission, row, request_number) - - -def patch_cross(submission: domain.Submission, row: models.Submission, - request_number: int = -1) -> domain.Submission: - req_type = domain.CrossListClassificationRequest - clsns = [domain.Classification(dbc.category) for dbc in row.categories - if not dbc.is_primary - and dbc.category not in submission.secondary_categories] - data = {'classifications': clsns} - return _patch_request(req_type, data, submission, row, request_number) - - -def _patch_request(req_type: Type[UserRequest], data: Dict[str, Any], - submission: domain.Submission, row: models.Submission, - request_number: int = -1) -> domain.Submission: - status = req_type.WORKING - if row.is_announced(): - status = req_type.APPLIED - elif row.is_deleted(): - status = req_type.CANCELLED - elif row.is_rejected(): - status = req_type.REJECTED - elif not row.is_working(): - status = req_type.PENDING # Includes hold state. - data.update({'status': status}) - request_id = req_type.generate_request_id(submission, request_number) - - if request_number < 0: - creator = domain.User(native_id=row.submitter_id, - email=row.submitter_email) - user_request = req_type(creator=creator, created=row.get_created(), - updated=row.get_updated(), - request_id=request_id, **data) - else: - user_request = submission.user_requests[request_id] - if any([setattr_changed(user_request, field, value) - for field, value in data.items()]): - user_request.updated = row.get_updated() - submission.user_requests[request_id] = user_request - - if status == req_type.APPLIED: - submission = user_request.apply(submission) - return submission - - -def setattr_changed(obj: Any, field: str, value: Any) -> bool: - """ - Set an attribute on an object only if the value does not match provided. - - Parameters - ---------- - obj : object - field : str - The name of the attribute on ``obj`` to set. - value : object - - Returns - ------- - bool - True if the attribute was set; otherwise False. - - """ - if getattr(obj, field) != value: - setattr(obj, field, value) - return True - return False diff --git a/src/arxiv/submission/services/classic/proposal.py b/src/arxiv/submission/services/classic/proposal.py deleted file mode 100644 index 4f2b89d..0000000 --- a/src/arxiv/submission/services/classic/proposal.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Integration with classic proposals.""" - -from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound - -from . import models, util, log -from ... import domain -from ...domain.event import Event, SetPrimaryClassification, \ - AddSecondaryClassification, AddProposal -from ...domain.submission import Submission - - -def add(event: AddProposal, before: Submission, after: Submission) -> None: - """ - Add a category proposal to the database. - - The objective here is simply to create a new proposal entry in the classic - database when an :class:`domain.event.AddProposal` event is stored. - - Parameters - ---------- - event : :class:`event.Event` - The event being committed. - before : :class:`.domain.submission.Submission` - State of the submission before the event. - after : :class:`.domain.submission.Submission` - State of the submission after the event. - - """ - supported = [SetPrimaryClassification, AddSecondaryClassification] - if event.proposed_event_type not in supported: - return - - category = event.proposed_event_data['category'] - is_primary = event.proposed_event_type is SetPrimaryClassification - with util.transaction() as session: - try: - existing_proposal = session.query(models.CategoryProposal) \ - .filter(models.CategoryProposal.submission_id == after.submission_id) \ - .filter(models.CategoryProposal.category == category) \ - .one() - return # Proposal already exists. - except MultipleResultsFound: - return # Proposal already exists (in spades!). - except NoResultFound: - pass - comment = None - if event.comment: - comment = log.admin_log(event.creator.username, 'admin comment', - event.comment, - username=event.creator.username, - hostname=event.creator.hostname, - submission_id=after.submission_id) - - session.add( - models.CategoryProposal( - submission_id=after.submission_id, - category=category, - is_primary=int(is_primary), - user_id=event.creator.native_id, - updated=event.created, - proposal_status=models.CategoryProposal.UNRESOLVED, - proposal_comment=comment - ) - ) diff --git a/src/arxiv/submission/services/classic/tests/__init__.py b/src/arxiv/submission/services/classic/tests/__init__.py deleted file mode 100644 index 61a51b9..0000000 --- a/src/arxiv/submission/services/classic/tests/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Integration tests for the classic database service. - -These tests assume that SQLAlchemy's MySQL backend is implemented correctly: -instead of using a live MySQL database, they use an in-memory SQLite database. -This is mostly fine (they are intended to be more-or-less swappable). The one -iffy bit is the JSON datatype, which is not available by default in the SQLite -backend. We extend the SQLite engine with a JSON type in -:mod:`arxiv.submission.services.classic.util`. End to end tests with a live -MySQL database will provide more confidence in this area. -""" diff --git a/src/arxiv/submission/services/classic/tests/test_admin_log.py b/src/arxiv/submission/services/classic/tests/test_admin_log.py deleted file mode 100644 index f924d9f..0000000 --- a/src/arxiv/submission/services/classic/tests/test_admin_log.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for admin log integration.""" - -from unittest import TestCase, mock -import os -from datetime import datetime -from contextlib import contextmanager -import json -from pytz import UTC - -from flask import Flask - -from ....domain.agent import User, System -from ....domain.submission import Submission, Author -from ....domain.event import CreateSubmission, ConfirmPolicy, SetTitle -from .. import models, store_event, log, current_session - -from .util import in_memory_db - - -class TestAdminLog(TestCase): - """Test adding an admin long entry with :func:`.log.admin_log`.""" - - def test_add_admin_log_entry(self): - """Add a log entry.""" - with in_memory_db(): - log.admin_log( - "fooprogram", - "test", - "this is a test of the admin log", - username="foouser", - hostname="127.0.0.1", - submission_id=5 - ) - - session = current_session() - logs = session.query(models.AdminLogEntry).all() - self.assertEqual(len(logs), 1) - self.assertEqual(logs[0].program, "fooprogram") - self.assertEqual(logs[0].command, "test") - self.assertEqual(logs[0].logtext, - "this is a test of the admin log") - self.assertEqual(logs[0].username, "foouser") - self.assertEqual(logs[0].host, "127.0.0.1") - self.assertEqual(logs[0].submission_id, 5) - self.assertEqual(logs[0].paper_id, "submit/5") - self.assertFalse(logs[0].notify) - self.assertIsNone(logs[0].document_id) - - -class TestOnEvent(TestCase): - """Functions in :const:`.log.ON_EVENT` are called.""" - - def test_on_event(self): - """Function in :const:`.log.ON_EVENT` is called.""" - mock_handler = mock.MagicMock() - log.ON_EVENT[ConfirmPolicy] = [mock_handler] - user = User(12345, 'joe@joe.joe', username="joeuser", - endorsements=['physics.soc-ph', 'cs.DL']) - event = ConfirmPolicy(creator=user) - before = Submission(creator=user, owner=user, submission_id=42) - after = Submission(creator=user, owner=user, submission_id=42) - log.handle(event, before, after) - self.assertEqual(mock_handler.call_count, 1, - "Handler registered for ConfirmPolicy is called") - - def test_on_event_is_specific(self): - """Function in :const:`.log.ON_EVENT` are specific.""" - mock_handler = mock.MagicMock() - log.ON_EVENT[ConfirmPolicy] = [mock_handler] - user = User(12345, 'joe@joe.joe', username="joeuser", - endorsements=['physics.soc-ph', 'cs.DL']) - event = SetTitle(creator=user, title="foo title") - before = Submission(creator=user, owner=user, submission_id=42) - after = Submission(creator=user, owner=user, submission_id=42) - log.handle(event, before, after) - self.assertEqual(mock_handler.call_count, 0, - "Handler registered for ConfirmPolicy is not called") - - -class TestStoreEvent(TestCase): - """Test log integration when storing event.""" - - def test_store_event(self): - """Log handler is called when an event is stored.""" - mock_handler = mock.MagicMock() - log.ON_EVENT[CreateSubmission] = [mock_handler] - user = User(12345, 'joe@joe.joe', username="joeuser", - endorsements=['physics.soc-ph', 'cs.DL']) - event = CreateSubmission(creator=user, created=datetime.now(UTC)) - before = None - after = Submission(creator=user, owner=user, submission_id=42) - - with in_memory_db(): - store_event(event, before, after) - - self.assertEqual(mock_handler.call_count, 1, - "Handler registered for CreateSubmission is called") diff --git a/src/arxiv/submission/services/classic/tests/test_get_licenses.py b/src/arxiv/submission/services/classic/tests/test_get_licenses.py deleted file mode 100644 index 4814e2f..0000000 --- a/src/arxiv/submission/services/classic/tests/test_get_licenses.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for retrieving license information.""" - -from unittest import TestCase, mock - -from flask import Flask - -from ....domain.submission import License -from .. import models, get_licenses, current_session -from .util import in_memory_db - - -class TestGetLicenses(TestCase): - """Test :func:`.get_licenses`.""" - - def test_get_all_active_licenses(self): - """Return a :class:`.domain.License` for each active license.""" - # mock_util.json_factory.return_value = SQLiteJSON - - with in_memory_db(): - session = current_session() - session.add(models.License( - name="http://arxiv.org/licenses/assumed-1991-2003", - sequence=9, - label="Assumed arXiv.org perpetual, non-exclusive license to", - active=0 - )) - session.add(models.License( - name="http://creativecommons.org/licenses/publicdomain/", - sequence=4, - label="Creative Commons Public Domain Declaration", - active=1 - )) - session.commit() - licenses = get_licenses() - - self.assertEqual(len(licenses), 1, - "Only the active license should be returned.") - self.assertIsInstance(licenses[0], License, - "Should return License instances.") - self.assertEqual(licenses[0].uri, - "http://creativecommons.org/licenses/publicdomain/", - "Should use name column to populate License.uri") - self.assertEqual(licenses[0].name, - "Creative Commons Public Domain Declaration", - "Should use label column to populate License.name") diff --git a/src/arxiv/submission/services/classic/tests/test_get_submission.py b/src/arxiv/submission/services/classic/tests/test_get_submission.py deleted file mode 100644 index 29242e4..0000000 --- a/src/arxiv/submission/services/classic/tests/test_get_submission.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for retrieving submissions.""" - -from unittest import TestCase, mock -from datetime import datetime -from pytz import UTC -from flask import Flask - -from ....domain.agent import User, System -from ....domain.submission import License, Submission, Author -from ....domain.event import CreateSubmission, \ - FinalizeSubmission, SetPrimaryClassification, AddSecondaryClassification, \ - SetLicense, SetPrimaryClassification, ConfirmPolicy, \ - ConfirmContactInformation, SetTitle, SetAbstract, SetDOI, \ - SetMSCClassification, SetACMClassification, SetJournalReference, \ - SetComments, SetAuthors, Announce, ConfirmAuthorship, ConfirmPolicy, \ - SetUploadPackage -from .. import init_app, create_all, drop_all, models, DBEvent, \ - get_submission, get_user_submissions_fast, current_session, get_licenses, \ - exceptions, store_event, transaction - -from .util import in_memory_db - - -class TestGetSubmission(TestCase): - """Test :func:`.classic.get_submission`.""" - - def test_get_submission_that_does_not_exist(self): - """Test that an exception is raised when submission doesn't exist.""" - with in_memory_db(): - with self.assertRaises(exceptions.NoSuchSubmission): - get_submission(1) - - def test_get_submission_with_publish(self): - """Test that publication state is reflected in submission data.""" - user = User(12345, 'joe@joe.joe', - endorsements=['physics.soc-ph', 'cs.DL']) - - events = [ - CreateSubmission(creator=user), - SetTitle(creator=user, title='Foo title'), - SetAbstract(creator=user, abstract='Indeed' * 10), - SetAuthors(creator=user, authors=[ - Author(order=0, forename='Joe', surname='Bloggs', - email='joe@blo.ggs'), - Author(order=1, forename='Jane', surname='Doe', - email='j@doe.com'), - ]), - SetLicense(creator=user, license_uri='http://foo.org/1.0/', - license_name='Foo zero 1.0'), - SetPrimaryClassification(creator=user, category='cs.DL'), - ConfirmPolicy(creator=user), - SetUploadPackage(creator=user, identifier='12345'), - ConfirmContactInformation(creator=user), - FinalizeSubmission(creator=user) - ] - - with in_memory_db(): - # User creates and finalizes submission. - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events[i] = event - before = after - submission = after - - ident = submission.submission_id - - session = current_session() - # Moderation happens, things change outside the event model. - db_submission = session.query(models.Submission).get(ident) - - # Announced! - db_submission.status = db_submission.ANNOUNCED - db_document = models.Document(paper_id='1901.00123') - db_submission.document = db_document - session.add(db_submission) - session.add(db_document) - session.commit() - - # Now get the submission. - submission_loaded, _ = get_submission(ident) - - self.assertEqual(submission.metadata.title, - submission_loaded.metadata.title, - "Event-derived metadata should be preserved.") - self.assertEqual(submission_loaded.arxiv_id, "1901.00123", - "arXiv paper ID should be set") - self.assertEqual(submission_loaded.status, Submission.ANNOUNCED, - "Submission status should reflect publish action") - - def test_get_submission_with_hold_and_reclass(self): - """Test changes made externally are reflected in submission data.""" - user = User(12345, 'joe@joe.joe', - endorsements=['physics.soc-ph', 'cs.DL']) - events = [ - CreateSubmission(creator=user), - SetTitle(creator=user, title='Foo title'), - SetAbstract(creator=user, abstract='Indeed' * 20), - SetAuthors(creator=user, authors=[ - Author(order=0, forename='Joe', surname='Bloggs', - email='joe@blo.ggs'), - Author(order=1, forename='Jane', surname='Doe', - email='j@doe.com'), - ]), - SetLicense(creator=user, license_uri='http://foo.org/1.0/', - license_name='Foo zero 1.0'), - SetPrimaryClassification(creator=user, category='cs.DL'), - ConfirmPolicy(creator=user), - SetUploadPackage(creator=user, identifier='12345'), - ConfirmContactInformation(creator=user), - FinalizeSubmission(creator=user) - ] - - with in_memory_db(): - # User creates and finalizes submission. - with transaction(): - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events[i] = event - before = after - submission = after - ident = submission.submission_id - - session = current_session() - # Moderation happens, things change outside the event model. - db_submission = session.query(models.Submission).get(ident) - - # Reclassification! - session.delete(db_submission.primary_classification) - session.add(models.SubmissionCategory( - submission_id=ident, category='cs.IR', is_primary=1 - )) - - # On hold! - db_submission.status = db_submission.ON_HOLD - session.add(db_submission) - session.commit() - - # Now get the submission. - submission_loaded, _ = get_submission(ident) - - self.assertEqual(submission.metadata.title, - submission_loaded.metadata.title, - "Event-derived metadata should be preserved.") - self.assertEqual(submission_loaded.primary_classification.category, - "cs.IR", - "Primary classification should reflect the" - " reclassification that occurred outside the purview" - " of the event model.") - self.assertEqual(submission_loaded.status, Submission.SUBMITTED, - "Submission status should still be submitted.") - self.assertTrue(submission_loaded.is_on_hold, - "Hold status should reflect hold action performed" - " outside the purview of the event model.") - - def test_get_submission_list(self): - """Test that the set of submissions for a user can be retrieved.""" - user = User(42, 'adent@example.org', - endorsements=['astro-ph.GA', 'astro-ph.EP']) - events1 = [ - # first submission - CreateSubmission(creator=user), - SetTitle(creator=user, title='Foo title'), - SetAbstract(creator=user, abstract='Indeed' * 20), - SetAuthors(creator=user, authors=[ - Author(order=0, forename='Arthur', surname='Dent', - email='adent@example.org'), - Author(order=1, forename='Ford', surname='Prefect', - email='fprefect@example.org'), - ]), - SetLicense(creator=user, license_uri='http://creativecommons.org/publicdomain/zero/1.0/', - license_name='Foo zero 1.0'), - SetPrimaryClassification(creator=user, category='astro-ph.GA'), - ConfirmPolicy(creator=user), - SetUploadPackage(creator=user, identifier='1'), - ConfirmContactInformation(creator=user), - FinalizeSubmission(creator=user) - ] - events2 = [ - # second submission - CreateSubmission(creator=user), - SetTitle(creator=user, title='Bar title'), - SetAbstract(creator=user, abstract='Indubitably' * 20), - SetAuthors(creator=user, authors=[ - Author(order=0, forename='Jane', surname='Doe', - email='jadoe@example.com'), - Author(order=1, forename='John', surname='Doe', - email='jodoe@example.com'), - ]), - SetLicense(creator=user, license_uri='http://creativecommons.org/publicdomain/zero/1.0/', - license_name='Foo zero 1.0'), - SetPrimaryClassification(creator=user, category='astro-ph.GA'), - ConfirmPolicy(creator=user), - SetUploadPackage(creator=user, identifier='1'), - ConfirmContactInformation(creator=user), - FinalizeSubmission(creator=user) - ] - - with in_memory_db(): - # User creates and finalizes submission. - with transaction(): - before = None - for i, event in enumerate(list(events1)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events1[i] = event - before = after - submission1 = after - ident1 = submission1.submission_id - - before = None - for i, event in enumerate(list(events2)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events2[i] = event - before = after - submission2 = after - ident2 = submission2.submission_id - - classic_sub = models.Submission( - type='new', - submitter_id=42) - session = current_session() - session.add(classic_sub) - - # Now get the submissions for this user. - submissions = get_user_submissions_fast(42) - submission_loaded1, _ = get_submission(ident1) - submission_loaded2, _ = get_submission(ident2) - - self.assertEqual(submission1.metadata.title, - submission_loaded1.metadata.title, - "Event-derived metadata for submission 1 should be preserved.") - self.assertEqual(submission2.metadata.title, - submission_loaded2.metadata.title, - "Event-derived metadata for submission 2 should be preserved.") - - self.assertEqual(len(submissions), - 2, - "There should be exactly two NG submissions.") - diff --git a/src/arxiv/submission/services/classic/tests/test_store_annotations.py b/src/arxiv/submission/services/classic/tests/test_store_annotations.py deleted file mode 100644 index ed0dfd3..0000000 --- a/src/arxiv/submission/services/classic/tests/test_store_annotations.py +++ /dev/null @@ -1 +0,0 @@ -"""Test persistence of annotations in the classic database.""" diff --git a/src/arxiv/submission/services/classic/tests/test_store_event.py b/src/arxiv/submission/services/classic/tests/test_store_event.py deleted file mode 100644 index 3fb655b..0000000 --- a/src/arxiv/submission/services/classic/tests/test_store_event.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Tests for storing events.""" - -from unittest import TestCase, mock -from datetime import datetime -from pytz import UTC -from flask import Flask - -from ....domain.agent import User, System -from ....domain.submission import License, Submission, Author -from ....domain.event import CreateSubmission, \ - FinalizeSubmission, SetPrimaryClassification, AddSecondaryClassification, \ - SetLicense, ConfirmPolicy, ConfirmContactInformation, SetTitle, \ - SetAbstract, SetDOI, SetMSCClassification, SetACMClassification, \ - SetJournalReference, SetComments, SetAuthors, Announce, \ - ConfirmAuthorship, SetUploadPackage -from .. import init_app, create_all, drop_all, models, DBEvent, \ - get_submission, current_session, get_licenses, exceptions, store_event, \ - transaction - - -from .util import in_memory_db - - -class TestStoreEvent(TestCase): - """Tests for :func:`.store_event`.""" - - def setUp(self): - """Instantiate a user.""" - self.user = User(12345, 'joe@joe.joe', - endorsements=['physics.soc-ph', 'cs.DL']) - - def test_store_creation(self): - """Store a :class:`CreateSubmission`.""" - with in_memory_db(): - session = current_session() - before = None - event = CreateSubmission(creator=self.user) - event.created = datetime.now(UTC) - after = event.apply(before) - - event, after = store_event(event, before, after) - - db_sb = session.query(models.Submission).get(event.submission_id) - - # Make sure that we get the right submission ID. - self.assertIsNotNone(event.submission_id) - self.assertEqual(event.submission_id, after.submission_id) - self.assertEqual(event.submission_id, db_sb.submission_id) - - self.assertEqual(db_sb.status, models.Submission.NOT_SUBMITTED) - self.assertEqual(db_sb.type, models.Submission.NEW_SUBMISSION) - self.assertEqual(db_sb.version, 1) - - def test_store_events_with_metadata(self): - """Store events and attendant submission with metadata.""" - metadata = { - 'title': 'foo title', - 'abstract': 'very abstract' * 20, - 'comments': 'indeed', - 'msc_class': 'foo msc', - 'acm_class': 'F.2.2; I.2.7', - 'doi': '10.1000/182', - 'journal_ref': 'Nature 1991 2: 1', - 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] - } - with in_memory_db(): - - ev = CreateSubmission(creator=self.user) - ev2 = SetTitle(creator=self.user, title=metadata['title']) - ev3 = SetAbstract(creator=self.user, abstract=metadata['abstract']) - ev4 = SetComments(creator=self.user, comments=metadata['comments']) - ev5 = SetMSCClassification(creator=self.user, - msc_class=metadata['msc_class']) - ev6 = SetACMClassification(creator=self.user, - acm_class=metadata['acm_class']) - ev7 = SetJournalReference(creator=self.user, - journal_ref=metadata['journal_ref']) - ev8 = SetDOI(creator=self.user, doi=metadata['doi']) - events = [ev, ev2, ev3, ev4, ev5, ev6, ev7, ev8] - - with transaction(): - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events[i] = event - before = after - - session = current_session() - db_submission = session.query(models.Submission)\ - .get(after.submission_id) - db_events = session.query(DBEvent).all() - - for key, value in metadata.items(): - if key == 'authors': - continue - self.assertEqual(getattr(db_submission, key), value, - f"The value of {key} should be {value}") - self.assertEqual(db_submission.authors, - after.metadata.authors_display, - "The canonical author string should be used to" - " update the submission in the database.") - - self.assertEqual(len(db_events), 8, - "Eight events should be stored") - for db_event in db_events: - self.assertEqual(db_event.submission_id, after.submission_id, - "The submission id should be set") - - def test_store_events_with_finalized_submission(self): - """Store events and a finalized submission.""" - metadata = { - 'title': 'foo title', - 'abstract': 'very abstract' * 20, - 'comments': 'indeed', - 'msc_class': 'foo msc', - 'acm_class': 'F.2.2; I.2.7', - 'doi': '10.1000/182', - 'journal_ref': 'Nature 1991 2: 1', - 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] - } - with in_memory_db(): - - events = [ - CreateSubmission(creator=self.user), - ConfirmContactInformation(creator=self.user), - ConfirmAuthorship(creator=self.user, submitter_is_author=True), - ConfirmContactInformation(creator=self.user), - ConfirmPolicy(creator=self.user), - SetTitle(creator=self.user, title=metadata['title']), - SetAuthors(creator=self.user, authors=[ - Author(order=0, forename='Joe', surname='Bloggs', - email='joe@blo.ggs'), - Author(order=1, forename='Jane', surname='Doe', - email='j@doe.com'), - ]), - SetAbstract(creator=self.user, abstract=metadata['abstract']), - SetComments(creator=self.user, comments=metadata['comments']), - SetMSCClassification(creator=self.user, - msc_class=metadata['msc_class']), - SetACMClassification(creator=self.user, - acm_class=metadata['acm_class']), - SetJournalReference(creator=self.user, - journal_ref=metadata['journal_ref']), - SetDOI(creator=self.user, doi=metadata['doi']), - SetLicense(creator=self.user, - license_uri='http://foo.org/1.0/', - license_name='Foo zero 1.0'), - SetUploadPackage(creator=self.user, identifier='12345'), - SetPrimaryClassification(creator=self.user, - category='physics.soc-ph'), - FinalizeSubmission(creator=self.user) - ] - - with transaction(): - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events[i] = event - before = after - - session = current_session() - db_submission = session.query(models.Submission) \ - .get(after.submission_id) - db_events = session.query(DBEvent).all() - - self.assertEqual(db_submission.submission_id, after.submission_id, - "The submission should be updated with the PK id") - self.assertEqual(db_submission.status, models.Submission.SUBMITTED, - "Submission should be in submitted state.") - self.assertEqual(len(db_events), len(events), - "%i events should be stored" % len(events)) - for db_event in db_events: - self.assertEqual(db_event.submission_id, after.submission_id, - "The submission id should be set") - - def test_store_doi_jref_with_publication(self): - """:class:`SetDOI` or :class:`SetJournalReference` after pub.""" - metadata = { - 'title': 'foo title', - 'abstract': 'very abstract' * 20, - 'comments': 'indeed', - 'msc_class': 'foo msc', - 'acm_class': 'F.2.2; I.2.7', - 'doi': '10.1000/182', - 'journal_ref': 'Nature 1991 2: 1', - 'authors': [Author(order=0, forename='Joe', surname='Bloggs')] - } - - with in_memory_db(): - events = [ - CreateSubmission(creator=self.user), - ConfirmContactInformation(creator=self.user), - ConfirmAuthorship(creator=self.user, submitter_is_author=True), - ConfirmContactInformation(creator=self.user), - ConfirmPolicy(creator=self.user), - SetTitle(creator=self.user, title=metadata['title']), - SetAuthors(creator=self.user, authors=[ - Author(order=0, forename='Joe', surname='Bloggs', - email='joe@blo.ggs'), - Author(order=1, forename='Jane', surname='Doe', - email='j@doe.com'), - ]), - SetAbstract(creator=self.user, abstract=metadata['abstract']), - SetComments(creator=self.user, comments=metadata['comments']), - SetMSCClassification(creator=self.user, - msc_class=metadata['msc_class']), - SetACMClassification(creator=self.user, - acm_class=metadata['acm_class']), - SetJournalReference(creator=self.user, - journal_ref=metadata['journal_ref']), - SetDOI(creator=self.user, doi=metadata['doi']), - SetLicense(creator=self.user, - license_uri='http://foo.org/1.0/', - license_name='Foo zero 1.0'), - SetUploadPackage(creator=self.user, identifier='12345'), - SetPrimaryClassification(creator=self.user, - category='physics.soc-ph'), - FinalizeSubmission(creator=self.user) - ] - - with transaction(): - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event = store_event(event, before, after) - events[i] = event - before = after - - session = current_session() - # Announced! - paper_id = '1901.00123' - db_submission = session.query(models.Submission) \ - .get(after.submission_id) - db_submission.status = db_submission.ANNOUNCED - db_document = models.Document(paper_id=paper_id) - db_submission.doc_paper_id = paper_id - db_submission.document = db_document - session.add(db_submission) - session.add(db_document) - session.commit() - - # This would normally happen during a load. - pub = Announce(creator=System(__name__), arxiv_id=paper_id, - committed=True) - before = pub.apply(before) - - # Now set DOI + journal ref - doi = '10.1000/182' - journal_ref = 'foo journal 1994' - e3 = SetDOI(creator=self.user, doi=doi, - submission_id=after.submission_id, - created=datetime.now(UTC)) - after = e3.apply(before) - with transaction(): - store_event(e3, before, after) - - e4 = SetJournalReference(creator=self.user, - journal_ref=journal_ref, - submission_id=after.submission_id, - created=datetime.now(UTC)) - before = after - after = e4.apply(before) - with transaction(): - store_event(e4, before, after) - - session = current_session() - # What happened. - db_submission = session.query(models.Submission) \ - .filter(models.Submission.doc_paper_id == paper_id) \ - .order_by(models.Submission.submission_id.desc()) - self.assertEqual(db_submission.count(), 2, - "Creates a second row for the JREF") - db_jref = db_submission.first() - self.assertTrue(db_jref.is_jref()) - self.assertEqual(db_jref.doi, doi) - self.assertEqual(db_jref.journal_ref, journal_ref) - - def test_store_events_with_classification(self): - """Store events including classification.""" - ev = CreateSubmission(creator=self.user) - ev2 = SetPrimaryClassification(creator=self.user, - category='physics.soc-ph') - ev3 = AddSecondaryClassification(creator=self.user, - category='physics.acc-ph') - events = [ev, ev2, ev3] - - with in_memory_db(): - with transaction(): - before = None - for i, event in enumerate(list(events)): - event.created = datetime.now(UTC) - after = event.apply(before) - event, after = store_event(event, before, after) - events[i] = event - before = after - - session = current_session() - db_submission = session.query(models.Submission)\ - .get(after.submission_id) - db_events = session.query(DBEvent).all() - - self.assertEqual(db_submission.submission_id, after.submission_id, - "The submission should be updated with the PK id") - self.assertEqual(len(db_events), 3, - "Three events should be stored") - for db_event in db_events: - self.assertEqual(db_event.submission_id, after.submission_id, - "The submission id should be set") - self.assertEqual(len(db_submission.categories), 2, - "Two category relations should be set") - self.assertEqual(db_submission.primary_classification.category, - after.primary_classification.category, - "Primary classification should be set.") diff --git a/src/arxiv/submission/services/classic/tests/test_store_proposals.py b/src/arxiv/submission/services/classic/tests/test_store_proposals.py deleted file mode 100644 index 0d10bf4..0000000 --- a/src/arxiv/submission/services/classic/tests/test_store_proposals.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Test persistence of proposals in the classic database.""" - -from unittest import TestCase, mock -from datetime import datetime -from pytz import UTC -from ....domain.event import CreateSubmission, SetPrimaryClassification, \ - AddSecondaryClassification, SetTitle, AddProposal -from ....domain.agent import User -from ....domain.annotation import Comment -from ....domain.submission import Submission -from ....domain.proposal import Proposal -from .. import store_event, models, get_events, current_session, transaction - -from .util import in_memory_db - -from arxiv import taxonomy - - -class TestSaveProposal(TestCase): - """An :class:`AddProposal` event is stored.""" - - def setUp(self): - """Instantiate a user.""" - self.user = User(12345, 'joe@joe.joe', - endorsements=['physics.soc-ph', 'cs.DL']) - - def test_save_reclassification_proposal(self): - """A submission has a new reclassification proposal.""" - with in_memory_db(): - create = CreateSubmission(creator=self.user, - created=datetime.now(UTC)) - before, after = None, create.apply(None) - create, before = store_event(create, before, after) - - event = AddProposal( - creator=self.user, - proposed_event_type=SetPrimaryClassification, - proposed_event_data={ - 'category': taxonomy.Category('cs.DL'), - }, - comment='foo', - created=datetime.now(UTC) - ) - after = event.apply(before) - with transaction(): - event, after = store_event(event, before, after) - - session = current_session() - db_sb = session.query(models.Submission).get(event.submission_id) - - # Make sure that we get the right submission ID. - self.assertIsNotNone(event.submission_id) - self.assertEqual(event.submission_id, after.submission_id) - self.assertEqual(event.submission_id, db_sb.submission_id) - - db_props = session.query(models.CategoryProposal).all() - self.assertEqual(len(db_props), 1) - self.assertEqual(db_props[0].submission_id, after.submission_id) - self.assertEqual(db_props[0].category, 'cs.DL') - self.assertEqual(db_props[0].is_primary, 1) - self.assertEqual(db_props[0].updated.replace(tzinfo=UTC), - event.created) - self.assertEqual(db_props[0].proposal_status, - models.CategoryProposal.UNRESOLVED) - - self.assertEqual(db_props[0].proposal_comment.logtext, - event.comment) - - def test_save_secondary_proposal(self): - """A submission has a new cross-list proposal.""" - with in_memory_db(): - create = CreateSubmission(creator=self.user, - created=datetime.now(UTC)) - before, after = None, create.apply(None) - create, before = store_event(create, before, after) - - event = AddProposal( - creator=self.user, - created=datetime.now(UTC), - proposed_event_type=AddSecondaryClassification, - proposed_event_data={ - 'category': taxonomy.Category('cs.DL'), - }, - comment='foo' - ) - after = event.apply(before) - with transaction(): - event, after = store_event(event, before, after) - - session = current_session() - db_sb = session.query(models.Submission).get(event.submission_id) - - # Make sure that we get the right submission ID. - self.assertIsNotNone(event.submission_id) - self.assertEqual(event.submission_id, after.submission_id) - self.assertEqual(event.submission_id, db_sb.submission_id) - - db_props = session.query(models.CategoryProposal).all() - self.assertEqual(len(db_props), 1) - self.assertEqual(db_props[0].submission_id, after.submission_id) - self.assertEqual(db_props[0].category, 'cs.DL') - self.assertEqual(db_props[0].is_primary, 0) - self.assertEqual(db_props[0].updated.replace(tzinfo=UTC), - event.created) - self.assertEqual(db_props[0].proposal_status, - models.CategoryProposal.UNRESOLVED) - - self.assertEqual(db_props[0].proposal_comment.logtext, - event.comment) - - def test_save_title_proposal(self): - """A submission has a new SetTitle proposal.""" - with in_memory_db(): - create = CreateSubmission(creator=self.user, - created=datetime.now(UTC)) - before, after = None, create.apply(None) - create, before = store_event(create, before, after) - - event = AddProposal( - creator=self.user, - created=datetime.now(UTC), - proposed_event_type=SetTitle, - proposed_event_data={'title': 'the foo title'}, - comment='foo' - ) - after = event.apply(before) - with transaction(): - event, after = store_event(event, before, after) - - session = current_session() - db_sb = session.query(models.Submission).get(event.submission_id) - - # Make sure that we get the right submission ID. - self.assertIsNotNone(event.submission_id) - self.assertEqual(event.submission_id, after.submission_id) - self.assertEqual(event.submission_id, db_sb.submission_id) - - db_props = session.query(models.CategoryProposal).all() - self.assertEqual(len(db_props), 0) diff --git a/src/arxiv/submission/services/classic/tests/util.py b/src/arxiv/submission/services/classic/tests/util.py deleted file mode 100644 index 06b799f..0000000 --- a/src/arxiv/submission/services/classic/tests/util.py +++ /dev/null @@ -1,24 +0,0 @@ -from contextlib import contextmanager - -from flask import Flask - -from .. import init_app, create_all, drop_all, models, DBEvent, \ - get_submission, current_session, get_licenses, exceptions, store_event - - -@contextmanager -def in_memory_db(app=None): - """Provide an in-memory sqlite database for testing purposes.""" - if app is None: - app = Flask('foo') - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - init_app(app) - with app.app_context(): - create_all() - try: - yield - except Exception: - raise - finally: - drop_all() diff --git a/src/arxiv/submission/services/classic/util.py b/src/arxiv/submission/services/classic/util.py deleted file mode 100644 index 5828fb4..0000000 --- a/src/arxiv/submission/services/classic/util.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Utility classes and functions for :mod:`.services.classic`.""" - -import json -from contextlib import contextmanager -from typing import Optional, Generator, Union, Any - -import sqlalchemy.types as types -from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import create_engine -from sqlalchemy.engine import Engine -from sqlalchemy.orm.session import Session -from sqlalchemy.orm import sessionmaker - -from arxiv.base import logging -from arxiv.base.globals import get_application_config, get_application_global -from .exceptions import ClassicBaseException, TransactionFailed -from ... import serializer -from ...exceptions import InvalidEvent - -logger = logging.getLogger(__name__) - -class ClassicSQLAlchemy(SQLAlchemy): - """SQLAlchemy integration for the classic database.""" - - def init_app(self, app: Flask) -> None: - """Set default configuration.""" - logger.debug('SQLALCHEMY_DATABASE_URI %s', - app.config.get('SQLALCHEMY_DATABASE_URI', 'Not Set')) - logger.debug('CLASSIC_DATABASE_URI %s', - app.config.get('CLASSIC_DATABASE_URI', 'Not Set')) - app.config.setdefault( - 'SQLALCHEMY_DATABASE_URI', - app.config.get('CLASSIC_DATABASE_URI', 'sqlite://') - ) - app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) - # Debugging - app.config.setdefault('SQLALCHEMY_POOL_SIZE', 1) - - super(ClassicSQLAlchemy, self).init_app(app) - - def apply_pool_defaults(self, app: Flask, options: Any) -> None: - """Set options for create_engine().""" - super(ClassicSQLAlchemy, self).apply_pool_defaults(app, options) - if app.config['SQLALCHEMY_DATABASE_URI'].startswith('mysql'): - options['json_serializer'] = serializer.dumps - options['json_deserializer'] = serializer.loads - - -db: SQLAlchemy = ClassicSQLAlchemy() - - -#logger = logging.getLogger(__name__) - - -class SQLiteJSON(types.TypeDecorator): - """A SQLite-friendly JSON data type.""" - - impl = types.TEXT - - def process_bind_param(self, value: Optional[dict], dialect: str) \ - -> Optional[str]: - """Serialize a dict to JSON.""" - if value is not None: - obj: Optional[str] = serializer.dumps(value) - else: - obj = value - return obj - - def process_result_value(self, value: str, dialect: str) \ - -> Optional[Union[str, dict]]: - """Deserialize JSON content to a dict.""" - if value is not None: - value = serializer.loads(value) - return value - - -# SQLite does not support JSON, so we extend JSON to use our custom data type -# as a variant for the 'sqlite' dialect. -FriendlyJSON = types.JSON().with_variant(SQLiteJSON, 'sqlite') - - -def current_engine() -> Engine: - """Get/create :class:`.Engine` for this context.""" - return db.engine - - -def current_session() -> Session: - """Get/create :class:`.Session` for this context.""" - return db.session() - - -@contextmanager -def transaction() -> Generator: - """Context manager for database transaction.""" - session = current_session() - logger.debug('transaction with session %s', id(session)) - try: - yield session - # Only commit if there are un-flushed changes. The caller may commit - # explicitly, e.g. to do exception handling. - if session.dirty or session.deleted or session.new: - session.commit() - logger.debug('committed!') - except ClassicBaseException as e: - logger.debug('Command failed, rolling back: %s', str(e)) - session.rollback() - raise # Propagate exceptions raised from this module. - except InvalidEvent: - session.rollback() - raise - except Exception as e: - logger.debug('Command failed, rolling back: %s', str(e)) - session.rollback() - raise TransactionFailed('Failed to execute transaction') from e diff --git a/src/arxiv/submission/services/classifier/__init__.py b/src/arxiv/submission/services/classifier/__init__.py deleted file mode 100644 index 626a146..0000000 --- a/src/arxiv/submission/services/classifier/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Integration with the classic classifier service. - -The classifier analyzes the text of the specified paper and returns -a list of suggested categories based on similarity comparisons performed -between the text of the paper and statistics for each category. - -Typically used to evaluate article classification prior to review by -moderators. - -Unlike the original arXiv::Classifier module, this module contains no real -business-logic: the objective is simply to provide a user-friendly calling -API. -""" - -from .classifier import Classifier diff --git a/src/arxiv/submission/services/classifier/classifier.py b/src/arxiv/submission/services/classifier/classifier.py deleted file mode 100644 index 1d09d18..0000000 --- a/src/arxiv/submission/services/classifier/classifier.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Classifier service integration.""" - -from typing import Tuple, List, Any, Union, NamedTuple, Optional -from math import exp, log -from functools import wraps - -import logging -from arxiv.taxonomy import Category -from arxiv.integration.api import status, service - -logger = logging.getLogger(__name__) - - -class Flag(NamedTuple): - """General-purpose QA flag.""" - - key: str - value: Union[int, str, dict] - - -class Suggestion(NamedTuple): - """A category suggested by the classifier.""" - - category: Category - probability: float - - -class Counts(NamedTuple): - """Various counts of paper content.""" - - chars: int - pages: int - stops: int - words: int - - -class Classifier(service.HTTPIntegration): - """Represents an interface to the classifier service.""" - - VERSION = '0.0' - SERVICE = 'classic' - - ClassifierResponse = Tuple[List[Suggestion], List[Flag], Optional[Counts]] - - class Meta: - """Configuration for :class:`Classifier`.""" - - service_name = "classifier" - - def __init__(self, endpoint: str, verify: bool = True, **params: Any): - super(Classifier, self).__init__(endpoint, verify=verify, **params) - - def is_available(self, **kwargs: Any) -> bool: - """Check our connection to the classifier service.""" - timeout: float = kwargs.get('timeout', 0.2) - try: - self.classify(b'ruok?', timeout=timeout) - except Exception as e: - logger.error('Encountered error calling classifier: %s', e) - return False - return True - - @classmethod - def probability(cls, logodds: float) -> float: - """Convert log odds to a probability.""" - return exp(logodds)/(1 + exp(logodds)) - - def _counts(self, data: dict) -> Optional[Counts]: - """Parse counts from the response data.""" - counts: Optional[Counts] = None - if 'counts' in data: - counts = Counts(**data['counts']) - return counts - - def _flags(self, data: dict) -> List[Flag]: - """Parse flags from the response data.""" - return [ - Flag(key, value) for key, value in data.get('flags', {}).items() - ] - - def _suggestions(self, data: dict) -> List[Suggestion]: - """Parse classification suggestions from the response data.""" - return [Suggestion(category=Category(datum['category']), - probability=self.probability(datum['logodds'])) - for datum in data['classifier']] - - def classify(self, content: bytes, timeout: float = 1.) \ - -> ClassifierResponse: - """ - Make a classification request to the classifier service. - - Parameters - ---------- - content : bytes - Raw text content from an e-print. - - Returns - ------- - list - A list of classifications. - list - A list of QA flags. - :class:`Counts` or None - Feature counts, if provided. - - """ - data, _, _ = self.json('post', '', data=content, timeout=timeout) - return self._suggestions(data), self._flags(data), self._counts(data) diff --git a/src/arxiv/submission/services/classifier/tests/__init__.py b/src/arxiv/submission/services/classifier/tests/__init__.py deleted file mode 100644 index 25c6518..0000000 --- a/src/arxiv/submission/services/classifier/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for classic classifier service integration.""" diff --git a/src/arxiv/submission/services/classifier/tests/data/linenos.json b/src/arxiv/submission/services/classifier/tests/data/linenos.json deleted file mode 100644 index 7013c5d..0000000 --- a/src/arxiv/submission/services/classifier/tests/data/linenos.json +++ /dev/null @@ -1 +0,0 @@ -{"classifier": [{"category": "astro-ph.SR", "logodds": 1.21, "topwords": [{"taurus": 38}, {"tau": 45}, {"single stars": 30}, {"binaries": 34}, {"alma": 37}]}, {"category": "astro-ph.GA", "logodds": 0.84, "topwords": [{"alma": 37}, {"stellar mass": 24}, {"taurus": 38}, {"disk mass": 33}, {"stars": 25}]}, {"category": "astro-ph.EP", "logodds": 0.8, "topwords": [{"disk mass": 33}, {"single stars": 30}, {"alma": 37}, {"binaries": 34}, {"taurus": 38}]}, {"category": "astro-ph.HE", "logodds": 0.29}, {"category": "astro-ph.IM", "logodds": 0.27}], "counts": {"chars": 125436, "pages": 30, "stops": 3774, "words": 34211}, "flags": {"%stop": 0.11, "linenos": 5}} diff --git a/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json b/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json deleted file mode 100644 index 7b98aa7..0000000 --- a/src/arxiv/submission/services/classifier/tests/data/sampleFailedCyrillic.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "classifier":[ - - ], - "counts":{ - "chars":50475, - "pages":8, - "stops":9, - "words":4799 - }, - "flags":{ - "%stop":0.0, - "charset":{ - "cyrillic":2458 - }, - "language":{ - "ru":732 - }, - "stops":9 - } -} diff --git a/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json b/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json deleted file mode 100644 index 8d7c6b9..0000000 --- a/src/arxiv/submission/services/classifier/tests/data/sampleResponse.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "classifier": [ - { - "category": "physics.comp-ph", - "logodds": -0.11, - "topwords": [ - { - "processors": 13 - }, - { - "fft": 13 - }, - { - "decyk": 4 - }, - { - "fast fourier transform": 7 - }, - { - "parallel": 10 - } - ] - }, - { - "category": "cs.MS", - "logodds": -0.14, - "topwords": [ - { - "fft": 13 - }, - { - "processors": 13 - }, - { - "fast fourier transform": 7 - }, - { - "parallel": 10 - }, - { - "processor": 7 - } - ] - }, - { - "category": "math.NA", - "logodds": -0.16, - "topwords": [ - { - "fft": 13 - }, - { - "fast fourier transform": 7 - }, - { - "algorithm": 6 - }, - { - "ux": 4 - }, - { - "multiplications": 5 - } - ] - } - ], - "counts": { - "chars": 15107, - "pages": 12, - "stops": 804, - "words": 2860 - }, - "flags": {} -} diff --git a/src/arxiv/submission/services/classifier/tests/tests.py b/src/arxiv/submission/services/classifier/tests/tests.py deleted file mode 100644 index 9e66950..0000000 --- a/src/arxiv/submission/services/classifier/tests/tests.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Tests for classic classifier service integration.""" - -import os -import json -from unittest import TestCase, mock - -from flask import Flask - -from arxiv.integration.api import status, exceptions - -from .. import classifier - -DATA_PATH = os.path.join(os.path.split(os.path.abspath(__file__))[0], "data") -SAMPLE_PATH = os.path.join(DATA_PATH, "sampleResponse.json") -LINENOS_PATH = os.path.join(DATA_PATH, "linenos.json") -SAMPLE_FAILED_PATH = os.path.join(DATA_PATH, 'sampleFailedCyrillic.json') - - -class TestClassifier(TestCase): - """Tests for :class:`classifier.Classifier`.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'CLASSIFIER_ENDPOINT': 'http://foohost:1234', - 'CLASSIFIER_VERIFY': False - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_with_service_unavailable(self, mock_Session): - """The classifier service is unavailable.""" - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.SERVICE_UNAVAILABLE - ) - ) - ) - with self.app.app_context(): - cl = classifier.Classifier.current_session() - with self.assertRaises(exceptions.RequestFailed): - cl.classify(b'somecontent') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_cannot_classify(self, mock_Session): - """The classifier returns without classification suggestions.""" - with open(SAMPLE_FAILED_PATH) as f: - data = json.load(f) - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value=data) - ) - ) - ) - with self.app.app_context(): - cl = classifier.Classifier.current_session() - suggestions, flags, counts = cl.classify(b'foo') - - self.assertEqual(len(suggestions), 0, "There are no suggestions") - self.assertEqual(len(flags), 4, "There are four flags") - self.assertEqual(counts.chars, 50475) - self.assertEqual(counts.pages, 8) - self.assertEqual(counts.stops, 9) - self.assertEqual(counts.words, 4799) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_returns_suggestions(self, mock_Session): - """The classifier returns classification suggestions.""" - with open(SAMPLE_PATH) as f: - data = json.load(f) - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value=data) - ) - ) - ) - expected = { - 'physics.comp-ph': 0.47, - 'cs.MS': 0.47, - 'math.NA': 0.46 - } - with self.app.app_context(): - cl = classifier.Classifier.current_session() - suggestions, flags, counts = cl.classify(b'foo') - - self.assertEqual(len(suggestions), 3, "There are three suggestions") - for suggestion in suggestions: - self.assertEqual(round(suggestion.probability, 2), - expected[suggestion.category]) - self.assertEqual(len(flags), 0, "There are no flags") - self.assertEqual(counts.chars, 15107) - self.assertEqual(counts.pages, 12) - self.assertEqual(counts.stops, 804) - self.assertEqual(counts.words, 2860) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_withlinenos(self, mock_Session): - """The classifier returns classification suggestions.""" - with open(LINENOS_PATH) as f: - data = json.load(f) - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value=data) - ) - ) - ) - expected = { - 'astro-ph.SR': 0.77, - 'astro-ph.GA': 0.7, - 'astro-ph.EP': 0.69, - 'astro-ph.HE': 0.57, - 'astro-ph.IM': 0.57 - - } - - with self.app.app_context(): - cl = classifier.Classifier.current_session() - suggestions, flags, counts = cl.classify(b'foo') - - self.assertEqual(len(suggestions), 5, "There are five suggestions") - for suggestion in suggestions: - self.assertEqual( - round(suggestion.probability, 2), - expected[suggestion.category], - "Expected probability of %s for %s" % - (expected[suggestion.category], suggestion.category) - ) - self.assertEqual(len(flags), 2, "There are two flags") - self.assertIn("%stop", [flag.key for flag in flags]) - self.assertIn("linenos", [flag.key for flag in flags]) - self.assertEqual(counts.chars, 125436) - self.assertEqual(counts.pages, 30) - self.assertEqual(counts.stops, 3774) - self.assertEqual(counts.words, 34211) - - -class TestClassifierModule(TestCase): - """Tests for :mod:`classifier`.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'CLASSIFIER_ENDPOINT': 'http://foohost:1234', - 'CLASSIFIER_VERIFY': False - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_unavailable(self, mock_Session): - """The classifier service is unavailable.""" - mock_post = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.SERVICE_UNAVAILABLE - ) - ) - mock_Session.return_value = mock.MagicMock(post=mock_post) - with self.app.app_context(): - cl = classifier.Classifier.current_session() - with self.assertRaises(exceptions.RequestFailed): - cl.classify(b'somecontent') - endpoint = f'http://foohost:1234/' - self.assertEqual(mock_post.call_args[0][0], endpoint) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_cannot_classify(self, mock_Session): - """The classifier returns without classification suggestions.""" - with open(SAMPLE_FAILED_PATH) as f: - data = json.load(f) - mock_post = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value=data) - ) - ) - mock_Session.return_value = mock.MagicMock(post=mock_post) - with self.app.app_context(): - cl = classifier.Classifier.current_session() - suggestions, flags, counts = cl.classify(b'foo') - - self.assertEqual(len(suggestions), 0, "There are no suggestions") - self.assertEqual(len(flags), 4, "There are four flags") - self.assertEqual(counts.chars, 50475) - self.assertEqual(counts.pages, 8) - self.assertEqual(counts.stops, 9) - self.assertEqual(counts.words, 4799) - endpoint = f'http://foohost:1234/' - self.assertEqual(mock_post.call_args[0][0], endpoint) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_classifier_returns_suggestions(self, mock_Session): - """The classifier returns classification suggestions.""" - with open(SAMPLE_PATH) as f: - data = json.load(f) - mock_post = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value=data) - ) - ) - mock_Session.return_value = mock.MagicMock(post=mock_post) - expected = { - 'physics.comp-ph': 0.47, - 'cs.MS': 0.47, - 'math.NA': 0.46 - } - - with self.app.app_context(): - cl = classifier.Classifier.current_session() - suggestions, flags, counts = cl.classify(b'foo') - - self.assertEqual(len(suggestions), 3, "There are three suggestions") - for suggestion in suggestions: - self.assertEqual(round(suggestion.probability, 2), - expected[suggestion.category]) - self.assertEqual(len(flags), 0, "There are no flags") - self.assertEqual(counts.chars, 15107) - self.assertEqual(counts.pages, 12) - self.assertEqual(counts.stops, 804) - self.assertEqual(counts.words, 2860) - endpoint = f'http://foohost:1234/' - self.assertEqual(mock_post.call_args[0][0], endpoint) diff --git a/src/arxiv/submission/services/compiler/__init__.py b/src/arxiv/submission/services/compiler/__init__.py deleted file mode 100644 index 1c8e0b2..0000000 --- a/src/arxiv/submission/services/compiler/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Integration with the compiler service API.""" - -from .compiler import Compiler, get_task_id, split_task_id, CompilationFailed diff --git a/src/arxiv/submission/services/compiler/compiler.py b/src/arxiv/submission/services/compiler/compiler.py deleted file mode 100644 index ece9e1b..0000000 --- a/src/arxiv/submission/services/compiler/compiler.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -Integration with the compiler service API. - -The compiler is responsible for building PDF, DVI, and other goodies from -LaTeX sources. In the submission UI, we specifically want to build a PDF so -that the user can preview their submission. Additionally, we want to show the -submitter the TeX log so that they can identify any potential problems with -their sources. -""" -import io -import json -import re -from collections import defaultdict -from enum import Enum -from functools import wraps -from typing import Tuple, Optional, List, Union, NamedTuple, Mapping, Any -from urllib.parse import urlparse, urlunparse, urlencode - -import dateutil.parser -import requests -from werkzeug.datastructures import FileStorage - -from arxiv.base import logging -from arxiv.integration.api import status, service - -from ...domain.compilation import Compilation, CompilationProduct, \ - CompilationLog - - -logger = logging.getLogger(__name__) - -PDF = Compilation.Format.PDF - - -class CompilationFailed(RuntimeError): - """The compilation service failed to compile the source package.""" - - -class Compiler(service.HTTPIntegration): - """Encapsulates a connection with the compiler service.""" - - SERVICE = 'compiler' - - VERSION = "30c84dd5b5381e2f2f69ed58298bd87c10bad5c8" - """Verison of the compiler service with which we are integrating.""" - - NAME = "arxiv-compiler" - """Name of the compiler service with which we are integrating.""" - - class Meta: - """Configuration for :class:`Classifier`.""" - - service_name = "compiler" - - def is_available(self, **kwargs: Any) -> bool: - """Check our connection to the compiler service.""" - timeout: float = kwargs.get('timeout', 0.2) - try: - self.get_service_status(timeout=timeout) - except Exception as e: - logger.error('Encountered error calling compiler: %s', e) - return False - return True - - def _parse_status_response(self, data: dict, headers: dict) -> Compilation: - return Compilation( - source_id=data['source_id'], - checksum=data['checksum'], - output_format=Compilation.Format(data['output_format']), - status=Compilation.Status(data['status']), - reason=Compilation.Reason(data.get('reason', None)), - description=data.get('description', None), - size_bytes=data.get('size_bytes', 0), - product_checksum=headers.get('ETag') - ) - - def _parse_loc(self, headers: Mapping) -> str: - return str(urlparse(headers['Location']).path) - - def get_service_status(self, timeout: float = 0.2) -> dict: - """Get the status of the compiler service.""" - data: dict = self.json('get', 'status', timeout=timeout)[0] - return data - - def compile(self, source_id: str, checksum: str, token: str, - stamp_label: str, stamp_link: str, - compiler: Optional[Compilation.SupportedCompiler] = None, - output_format: Compilation.Format = PDF, - force: bool = False) -> Compilation: - """ - Request compilation for an upload workspace. - - Unless ``force`` is ``True``, the compiler service will only attempt - to compile a source ID + checksum + format combo once. If there is - already a compilation underway or complete for the parameters in this - request, the service will redirect to the corresponding status URI. - Hence the data returned by this function may be from the response to - the initial POST request, or from the status endpoint after being - redirected. - - Parameters - ---------- - source_id : int - Unique identifier for the upload workspace. - checksum : str - State up of the upload workspace. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - stamp_label : str - Label to use in PS/PDF stamp/watermark. Form is - 'Identifier [Category Date]' - Category and Date are optional. By default Date will be added - by compiler. - stamp_link : str - Link (URI) to use in PS/PDF stamp/watermark. - compiler : :class:`.Compiler` or None - Name of the preferred compiler. - output_format : :class:`.Format` - Defaults to :attr:`.Format.PDF`. - force : bool - If True, compilation will be forced even if it has been attempted - with these parameters previously. Default is ``False``. - - Returns - ------- - :class:`Compilation` - The current state of the compilation. - - """ - logger.debug("Requesting compilation for %s @ %s: %s", - source_id, checksum, output_format) - payload = {'source_id': source_id, 'checksum': checksum, - 'stamp_label': stamp_label, 'stamp_link': stamp_link, - 'format': output_format.value, 'force': force} - endpoint = '/' - expected_codes = [status.OK, status.ACCEPTED, - status.SEE_OTHER, status.FOUND] - data, _, headers = self.json('post', endpoint, token, json=payload, - expected_code=expected_codes) - return self._parse_status_response(data, headers) - - def get_status(self, source_id: str, checksum: str, token: str, - output_format: Compilation.Format = PDF) -> Compilation: - """ - Get the status of a compilation. - - Parameters - ---------- - source_id : int - Unique identifier for the upload workspace. - checksum : str - State up of the upload workspace. - output_format : :class:`.Format` - Defaults to :attr:`.Format.PDF`. - - Returns - ------- - :class:`Compilation` - The current state of the compilation. - - """ - endpoint = f'/{source_id}/{checksum}/{output_format.value}' - data, _, headers = self.json('get', endpoint, token) - return self._parse_status_response(data, headers) - - def compilation_is_complete(self, source_id: str, checksum: str, - token: str, - output_format: Compilation.Format) -> bool: - """Check whether compilation has completed successfully.""" - stat = self.get_status(source_id, checksum, token, output_format) - if stat.status is Compilation.Status.SUCCEEDED: - return True - elif stat.status is Compilation.Status.FAILED: - raise CompilationFailed('Compilation failed') - return False - - def get_product(self, source_id: str, checksum: str, token: str, - output_format: Compilation.Format = PDF) \ - -> CompilationProduct: - """ - Get the compilation product for an upload workspace, if it exists. - - Parameters - ---------- - source_id : int - Unique identifier for the upload workspace. - checksum : str - State up of the upload workspace. - output_format : :class:`.Format` - Defaults to :attr:`.Format.PDF`. - - Returns - ------- - :class:`CompilationProduct` - The compilation product itself. - - """ - endpoint = f'/{source_id}/{checksum}/{output_format.value}/product' - response = self.request('get', endpoint, token, stream=True) - return CompilationProduct(content_type=output_format.content_type, - stream=io.BytesIO(response.content)) - - def get_log(self, source_id: str, checksum: str, token: str, - output_format: Compilation.Format = PDF) -> CompilationLog: - """ - Get the compilation log for an upload workspace, if it exists. - - Parameters - ---------- - source_id : int - Unique identifier for the upload workspace. - checksum : str - State up of the upload workspace. - output_format : :class:`.Format` - Defaults to :attr:`.Format.PDF`. - - Returns - ------- - :class:`CompilationProduct` - The compilation product itself. - - """ - endpoint = f'/{source_id}/{checksum}/{output_format.value}/log' - response = self.request('get', endpoint, token, stream=True) - return CompilationLog(stream=io.BytesIO(response.content)) - - -def get_task_id(source_id: str, checksum: str, - output_format: Compilation.Format) -> str: - """Generate a key for a /checksum/format combination.""" - return f"{source_id}/{checksum}/{output_format.value}" - - -def split_task_id(task_id: str) -> Tuple[str, str, Compilation.Format]: - source_id, checksum, format_value = task_id.split("/") - return source_id, checksum, Compilation.Format(format_value) - - -class Download(object): - """Wrapper around response content.""" - - def __init__(self, response: requests.Response) -> None: - """Initialize with a :class:`requests.Response` object.""" - self._response = response - - def read(self, *args: Any, **kwargs: Any) -> bytes: - """Read response content.""" - return self._response.content diff --git a/src/arxiv/submission/services/compiler/tests.py b/src/arxiv/submission/services/compiler/tests.py deleted file mode 100644 index 1c135b9..0000000 --- a/src/arxiv/submission/services/compiler/tests.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Tests for :mod:`.compiler`.""" - -from unittest import TestCase, mock - -from flask import Flask - -from arxiv.integration.api import status, exceptions - -from . import compiler -from ... import domain - - -class TestRequestCompilation(TestCase): - """Tests for :mod:`compiler.compile` with mocked responses.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'COMPILER_ENDPOINT': 'http://foohost:1234', - 'COMPILER_VERIFY': False - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_compile(self, mock_Session): - """Request compilation of an upload workspace.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - location = f'http://asdf/{source_id}/{checksum}/{output_format.value}' - in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value - mock_session = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.ACCEPTED, - json=mock.MagicMock(return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': in_progress - }), - headers={'Location': location} - ) - ), - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': domain.compilation.Compilation.Status.IN_PROGRESS.value - }), - headers={'Location': location} - ) - ) - ) - mock_Session.return_value = mock_session - - with self.app.app_context(): - cp = compiler.Compiler.current_session() - stat = cp.compile(source_id, checksum, 'footok', 'theLabel', - 'http://the.link') - self.assertEqual(stat.source_id, source_id) - self.assertEqual(stat.identifier, - f"{source_id}/{checksum}/{output_format.value}") - self.assertEqual(stat.status, - domain.compilation.Compilation.Status.IN_PROGRESS) - self.assertEqual(mock_session.post.call_count, 1) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_compile_redirects(self, mock_Session): - """Request compilation of an upload workspace already processing.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value - location = f'http://asdf/{source_id}/{checksum}/{output_format.value}' - mock_session = mock.MagicMock( - post=mock.MagicMock( # Redirected - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': in_progress - } - ) - ) - ) - ) - mock_Session.return_value = mock_session - with self.app.app_context(): - cp = compiler.Compiler.current_session() - stat = cp.compile(source_id, checksum, 'footok', 'theLabel', - 'http://the.link') - self.assertEqual(stat.source_id, source_id) - self.assertEqual(stat.identifier, - f"{source_id}/{checksum}/{output_format.value}") - self.assertEqual(stat.status, - domain.compilation.Compilation.Status.IN_PROGRESS) - self.assertEqual(mock_session.post.call_count, 1) - - -class TestGetTaskStatus(TestCase): - """Tests for :mod:`compiler.get_status` with mocked responses.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'COMPILER_ENDPOINT': 'http://foohost:1234', - 'COMPILER_VERIFY': False - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_get_status_failed(self, mock_Session): - """Get the status of a failed task.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - failed = domain.compilation.Compilation.Status.FAILED.value - mock_session = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': failed - } - ) - ) - ) - ) - mock_Session.return_value = mock_session - with self.app.app_context(): - cp = compiler.Compiler.current_session() - stat = cp.get_status(source_id, checksum, 'tok', output_format) - self.assertEqual(stat.source_id, source_id) - self.assertEqual(stat.identifier, - f"{source_id}/{checksum}/{output_format.value}") - self.assertEqual(stat.status, - domain.compilation.Compilation.Status.FAILED) - self.assertEqual(mock_session.get.call_count, 1) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_get_status_in_progress(self, mock_Session): - """Get the status of an in-progress task.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - in_progress = domain.compilation.Compilation.Status.IN_PROGRESS.value - mock_session = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': in_progress - } - ) - ) - ) - ) - mock_Session.return_value = mock_session - with self.app.app_context(): - cp = compiler.Compiler.current_session() - stat = cp.get_status(source_id, checksum, 'tok', output_format) - self.assertEqual(stat.source_id, source_id) - self.assertEqual(stat.identifier, - f"{source_id}/{checksum}/{output_format.value}") - self.assertEqual(stat.status, - domain.compilation.Compilation.Status.IN_PROGRESS) - self.assertEqual(mock_session.get.call_count, 1) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_get_status_completed(self, mock_Session): - """Get the status of a completed task.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - succeeded = domain.compilation.Compilation.Status.SUCCEEDED.value - mock_session = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={ - 'source_id': source_id, - 'checksum': checksum, - 'output_format': output_format.value, - 'status': succeeded - } - ) - ) - ) - ) - mock_Session.return_value = mock_session - with self.app.app_context(): - cp = compiler.Compiler.current_session() - stat = cp.get_status(source_id, checksum, 'tok', output_format) - self.assertEqual(stat.source_id, source_id) - self.assertEqual(stat.identifier, - f"{source_id}/{checksum}/{output_format.value}") - self.assertEqual(stat.status, - domain.compilation.Compilation.Status.SUCCEEDED) - self.assertEqual(mock_session.get.call_count, 1) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_get_status_doesnt_exist(self, mock_Session): - """Get the status of a task that does not exist.""" - source_id = 42 - checksum = 'asdf1234=' - output_format = domain.compilation.Compilation.Format.PDF - mock_session = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.NOT_FOUND, - json=mock.MagicMock( - return_value={} - ) - ) - ) - ) - mock_Session.return_value = mock_session - with self.app.app_context(): - cp = compiler.Compiler.current_session() - with self.assertRaises(exceptions.NotFound): - cp.get_status(source_id, checksum, 'footok', output_format) diff --git a/src/arxiv/submission/services/filemanager/__init__.py b/src/arxiv/submission/services/filemanager/__init__.py deleted file mode 100644 index eca5ee8..0000000 --- a/src/arxiv/submission/services/filemanager/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Integration with the file manager service.""" - -from .filemanager import Filemanager \ No newline at end of file diff --git a/src/arxiv/submission/services/filemanager/filemanager.py b/src/arxiv/submission/services/filemanager/filemanager.py deleted file mode 100644 index c700275..0000000 --- a/src/arxiv/submission/services/filemanager/filemanager.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Provides an integration with the file management service.""" - -from collections import defaultdict -from http import HTTPStatus as status -from typing import Tuple, List, IO, Mapping, Any - -import dateutil.parser -from werkzeug.datastructures import FileStorage - -from arxiv.base import logging -from arxiv.base.globals import get_application_config -from arxiv.integration.api import service - -from ...domain import SubmissionContent -from ...domain.uploads import Upload, FileStatus, FileError, UploadStatus, \ - UploadLifecycleStates -from ..util import ReadWrapper - -logger = logging.getLogger(__name__) - - -class Filemanager(service.HTTPIntegration): - """Encapsulates a connection with the file management service.""" - - SERVICE = 'filemanager' - VERSION = '37bf8d3' - - class Meta: - """Configuration for :class:`FileManager`.""" - - service_name = "filemanager" - - def has_single_file(self, upload_id: str, token: str, - file_type: str = 'PDF') -> bool: - """Checj whether an upload workspace one or more file of a type.""" - stat = self.get_upload_status(upload_id, token) - try: - next((f.name for f in stat.files if f.file_type == file_type)) - except StopIteration: # Empty iterator => no such file. - return False - return True - - def get_single_file(self, upload_id: str, token: str, - file_type: str = 'PDF') -> Tuple[IO[bytes], str, str]: - """ - Get a single PDF file from the submission package. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - token : str - Auth token to include in the request. - - Returns - ------- - bool - ``True`` if the source package consists of a single file. ``False`` - otherwise. - str - The checksum of the source package. - str - The checksum of the single file. - - """ - stat = self.get_upload_status(upload_id, token) - try: - pdf_name = next((f.name for f in stat.files - if f.file_type == file_type)) - except StopIteration as e: - raise RuntimeError(f'No single `{file_type}` file found.') from e - content, headers = self.get_file_content(upload_id, pdf_name, token) - if stat.checksum is None: - raise RuntimeError(f'Upload workspace checksum not set') - return content, stat.checksum, headers['ETag'] - - def is_available(self, **kwargs: Any) -> bool: - """Check our connection to the filemanager service.""" - config = get_application_config() - status_endpoint = config.get('FILEMANAGER_STATUS_ENDPOINT', 'status') - timeout: float = kwargs.get('timeout', 0.2) - try: - response = self.request('get', status_endpoint, timeout=timeout) - return bool(response.status_code == 200) - except Exception as e: - logger.error('Error when calling filemanager: %s', e) - return False - return True - - def _parse_upload_status(self, data: dict) -> Upload: - file_errors: Mapping[str, List[FileError]] = defaultdict(list) - non_file_errors = [] - filepaths = [fdata['public_filepath'] for fdata in data['files']] - for etype, filepath, message in data['errors']: - if filepath and filepath in filepaths: - file_errors[filepath].append(FileError(etype.upper(), message)) - else: # This includes messages for files that were removed. - non_file_errors.append(FileError(etype.upper(), message)) - - - return Upload( - started=dateutil.parser.parse(data['start_datetime']), - completed=dateutil.parser.parse(data['completion_datetime']), - created=dateutil.parser.parse(data['created_datetime']), - modified=dateutil.parser.parse(data['modified_datetime']), - status=UploadStatus(data['readiness']), - lifecycle=UploadLifecycleStates(data['upload_status']), - locked=bool(data['lock_state'] == 'LOCKED'), - identifier=data['upload_id'], - files=[ - FileStatus( - name=fdata['name'], - path=fdata['public_filepath'], - size=fdata['size'], - file_type=fdata['type'], - modified=dateutil.parser.parse(fdata['modified_datetime']), - errors=file_errors[fdata['public_filepath']] - ) for fdata in data['files'] - ], - errors=non_file_errors, - compressed_size=data['upload_compressed_size'], - size=data['upload_total_size'], - checksum=data['checksum'], - source_format=SubmissionContent.Format(data['source_format']) - ) - - def request_file(self, path: str, token: str) -> Tuple[IO[bytes], dict]: - """Perform a GET request for a file, and handle any exceptions.""" - response = self.request('get', path, token, stream=True) - stream = ReadWrapper(response.iter_content, - int(response.headers['Content-Length'])) - return stream, response.headers - - def upload_package(self, pointer: FileStorage, token: str) -> Upload: - """ - Stream an upload to the file management service. - - If the file is an archive (zip, tar-ball, etc), it will be unpacked. - A variety of processing and sanitization routines are performed, and - any errors or warnings (including deleted files) will be included in - the response body. - - Parameters - ---------- - pointer : :class:`FileStorage` - File upload stream from the client. - token : str - Auth token to include in the request. - - Returns - ------- - dict - A description of the upload package. - dict - Response headers. - - """ - files = {'file': (pointer.filename, pointer, pointer.mimetype)} - data, _, _ = self.json('post', '/', token, files=files, - expected_code=[status.CREATED, - status.OK], - timeout=30, allow_2xx_redirects=False) - return self._parse_upload_status(data) - - def get_upload_status(self, upload_id: str, token: str) -> Upload: - """ - Retrieve metadata about an accepted and processed upload package. - - Parameters - ---------- - upload_id : int - Unique long-lived identifier for the upload. - token : str - Auth token to include in the request. - - Returns - ------- - dict - A description of the upload package. - dict - Response headers. - - """ - data, _, _ = self.json('get', f'/{upload_id}', token) - return self._parse_upload_status(data) - - def add_file(self, upload_id: str, pointer: FileStorage, token: str, - ancillary: bool = False) -> Upload: - """ - Upload a file or package to an existing upload workspace. - - If the file is an archive (zip, tar-ball, etc), it will be unpacked. A - variety of processing and sanitization routines are performed. Existing - files will be overwritten by files of the same name. and any errors or - warnings (including deleted files) will be included in the response - body. - - Parameters - ---------- - upload_id : int - Unique long-lived identifier for the upload. - pointer : :class:`FileStorage` - File upload stream from the client. - token : str - Auth token to include in the request. - ancillary : bool - If ``True``, the file should be added as an ancillary file. - - Returns - ------- - dict - A description of the upload package. - dict - Response headers. - - """ - files = {'file': (pointer.filename, pointer, pointer.mimetype)} - data, _, _ = self.json('post', f'/{upload_id}', token, - data={'ancillary': ancillary}, files=files, - expected_code=[status.CREATED, status.OK], - timeout=30, allow_2xx_redirects=False) - return self._parse_upload_status(data) - - def delete_all(self, upload_id: str, token: str) -> Upload: - """ - Delete all files in the workspace. - - Does not delete the workspace itself. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - token : str - Auth token to include in the request. - - """ - data, _, _ = self.json('post', f'/{upload_id}/delete_all', token) - return self._parse_upload_status(data) - - def get_file_content(self, upload_id: str, file_path: str, token: str) \ - -> Tuple[IO[bytes], dict]: - """ - Get the content of a single file from the upload workspace. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - file_path : str - Path-like key for individual file in upload workspace. This is the - path relative to the root of the workspace. - token : str - Auth token to include in the request. - - Returns - ------- - :class:`ReadWrapper` - A ``read() -> bytes``-able wrapper around response content. - dict - Response headers. - - """ - return self.request_file(f'/{upload_id}/{file_path}/content', token) - - def delete_file(self, upload_id: str, file_path: str, token: str) \ - -> Upload: - """ - Delete a single file from the upload workspace. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - file_path : str - Path-like key for individual file in upload workspace. This is the - path relative to the root of the workspace. - token : str - Auth token to include in the request. - - Returns - ------- - dict - An empty dict. - dict - Response headers. - - """ - data, _, _ = self.json('delete', f'/{upload_id}/{file_path}', token) - return self._parse_upload_status(data) - - def get_upload_content(self, upload_id: str, token: str) \ - -> Tuple[IO[bytes], dict]: - """ - Retrieve the sanitized/processed upload package. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - token : str - Auth token to include in the request. - - Returns - ------- - :class:`ReadWrapper` - A ``read() -> bytes``-able wrapper around response content. - dict - Response headers. - - """ - return self.request_file(f'/{upload_id}/content', token) - - def get_logs(self, upload_id: str, token: str) -> Tuple[dict, dict]: - """ - Retrieve log files related to upload workspace. - - Indicates history or actions on workspace. - - Parameters - ---------- - upload_id : str - Unique long-lived identifier for the upload. - token : str - Auth token to include in the request. - - Returns - ------- - dict - Log data for the upload workspace. - dict - Response headers. - - """ - data, _, headers = self.json('post', f'/{upload_id}/logs', token) - return data, headers diff --git a/src/arxiv/submission/services/filemanager/tests/__init__.py b/src/arxiv/submission/services/filemanager/tests/__init__.py deleted file mode 100644 index 4f51240..0000000 --- a/src/arxiv/submission/services/filemanager/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for mod:`submit.services`.""" diff --git a/src/arxiv/submission/services/filemanager/tests/data/test.txt b/src/arxiv/submission/services/filemanager/tests/data/test.txt deleted file mode 100644 index f0c1996..0000000 --- a/src/arxiv/submission/services/filemanager/tests/data/test.txt +++ /dev/null @@ -1,9 +0,0 @@ -Bacon ipsum dolor amet venison pastrami short ribs pork belly, voluptate non labore mollit landjaeger. Ex porchetta ground round strip steak fatback turducken boudin pork chop ham. Bresaola eiusmod prosciutto fatback flank exercitation short ribs dolore meatball. Do fatback quis culpa in, andouille landjaeger ut pancetta excepteur ipsum turkey. - -Culpa pork belly boudin eiusmod. Pariatur boudin officia nostrud turkey ham esse laborum alcatra chicken ad drumstick beef. Spare ribs turkey t-bone voluptate, magna ham hock biltong ut pancetta ut in ribeye alcatra dolore landjaeger. Andouille velit salami spare ribs aliquip anim shankle pork belly burgdoggen. - -Pariatur laboris beef ribs biltong, ham hock ground round cillum jerky. Ball tip t-bone elit, bacon et bresaola velit sint tri-tip ipsum. Dolore commodo capicola, anim drumstick landjaeger nisi filet mignon enim in. Incididunt minim swine deserunt dolore. - -Salami aute et tail laboris ipsum ea. Pancetta aliquip alcatra porchetta velit fugiat laborum picanha. Cupidatat reprehenderit do, pastrami sirloin pork loin ipsum tail. Velit id alcatra adipisicing, short loin aliqua prosciutto tail flank cillum capicola. Reprehenderit irure commodo proident kevin cillum short loin sunt shoulder minim burgdoggen aute pastrami ut fatback. - -Non shankle anim incididunt tenderloin, eiusmod fatback landjaeger alcatra salami rump kevin strip steak. Eu cillum pancetta, filet mignon velit sirloin picanha ullamco laboris consectetur esse veniam sausage. Aliqua shoulder nisi occaecat porchetta tri-tip, esse ribeye fugiat pork chop flank. Strip steak dolore aliqua brisket labore sunt pig. diff --git a/src/arxiv/submission/services/filemanager/tests/data/test.zip b/src/arxiv/submission/services/filemanager/tests/data/test.zip deleted file mode 100644 index f0f9e77243e698360f5a6cd7a044ee136ccf56c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 896 zcmWIWW@Zs#U|`^2*yAGNJ9YZz4Y!yW7&6&FA~Fmmsl_FFB^4#1A)E}%%G;`<)quFP zf}4Sn@a*uj`{pK@5?!TU|jWhN?IWx0wVTJc^^;fUk{2#W;%~3bwekzn} z*j+KpY2&N!)1FD1ReiF$G+m>!Iax?IlXu!SiAfhH6AF4R3O|e3!WG3u$}XzTQu$J{mByP=H8-B-_|m2EK>elNoHQKqqw_8XyG&t>7)qBsu zwP$J1y16w+%`RLq>Yw88-e;BZb*8{^|F3SJH$7WaXqwoX+{lxkRgiq6GG^8NO^qi5 zt5!HShd#Fw4?4d}{$+grl~ua?_GLN!S>KSV>bb`L-6^Mr_}z6e29YZ!N8Vj|be_?} zS@ZKQYvz@|n6-88&BbRfd_Kc(v7vs^f$N)ArY>n*loL4b(OQ?a7Hzj*WS2aYIP`7p zbD0Hhzt)=uRDVlbEiGaY<$KG0U2K-bPrFsUSM8SH-R696VqM*V#T)PVIB;%d+9_7) z`mpZ%HLXzHk1;aq%9pOMdAn-f7lDs#bACQ(|2JrweOptg0 diff --git a/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py b/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py deleted file mode 100644 index 310890f..0000000 --- a/src/arxiv/submission/services/filemanager/tests/test_filemanager_integration.py +++ /dev/null @@ -1,255 +0,0 @@ -import io -import os -import subprocess -import time -from unittest import TestCase, mock - -import docker -from arxiv_auth.auth import scopes -from arxiv_auth.helpers import generate_token -from flask import Flask, Config -from werkzeug.datastructures import FileStorage - -from arxiv.integration.api import exceptions - -from ..filemanager import Filemanager -from ....domain.uploads import Upload, FileStatus, FileError, UploadStatus, \ - UploadLifecycleStates - -mock_app = Flask('test') -mock_app.config.update({ - 'FILEMANAGER_ENDPOINT': 'http://localhost:8003/filemanager/api', - 'FILEMANAGER_VERIFY': False -}) -Filemanager.init_app(mock_app) - - -class TestFilemanagerIntegration(TestCase): - - __test__ = int(bool(os.environ.get('WITH_INTEGRATION', False))) - - @classmethod - def setUpClass(cls): - """Start up the file manager service.""" - print('starting file management service') - client = docker.from_env() - image = f'arxiv/{Filemanager.SERVICE}' - # client.images.pull(image, tag=Filemanager.VERSION) - - cls.data = client.volumes.create(name='data', driver='local') - cls.filemanager = client.containers.run( - f'{image}:{Filemanager.VERSION}', - detach=True, - ports={'8000/tcp': 8003}, - volumes={'data': {'bind': '/data', 'mode': 'rw'}}, - environment={ - 'NAMESPACE': 'test', - 'JWT_SECRET': 'foosecret', - 'SQLALCHEMY_DATABASE_URI': 'sqlite:////opt/arxiv/foo.db', - 'STORAGE_BASE_PATH': '/data' - }, - command='/bin/bash -c "python bootstrap.py && uwsgi --ini /opt/arxiv/uwsgi.ini"' - ) - - time.sleep(5) - - os.environ['JWT_SECRET'] = 'foosecret' - cls.token = generate_token('1', 'u@ser.com', 'theuser', - scope=[scopes.WRITE_UPLOAD, - scopes.READ_UPLOAD]) - - @classmethod - def tearDownClass(cls): - """Tear down file management service once all tests have run.""" - cls.filemanager.kill() - cls.filemanager.remove() - cls.data.remove() - - def setUp(self): - """Create a new app for config and context.""" - self.app = Flask('test') - self.app.config.update({ - 'FILEMANAGER_ENDPOINT': 'http://localhost:8003', - }) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_upload_package(self): - """Upload a new package.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - data = fm.upload_package(pointer, self.token) - self.assertIsInstance(data, Upload) - self.assertEqual(data.status, UploadStatus.ERRORS) - self.assertEqual(data.lifecycle, UploadLifecycleStates.ACTIVE) - self.assertFalse(data.locked) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_upload_package_without_authorization(self): - """Upload a new package without authorization.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - token = generate_token('1', 'u@ser.com', 'theuser', - scope=[scopes.READ_UPLOAD]) - with self.assertRaises(exceptions.RequestForbidden): - fm.upload_package(pointer, token) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_upload_package_without_authentication_token(self): - """Upload a new package without an authentication token.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - with self.assertRaises(exceptions.RequestUnauthorized): - fm.upload_package(pointer, '') - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_get_upload_status(self): - """Get the status of an upload.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - data = fm.upload_package(pointer, self.token) - - status = fm.get_upload_status(data.identifier, self.token) - self.assertIsInstance(status, Upload) - self.assertEqual(status.status, UploadStatus.ERRORS) - self.assertEqual(status.lifecycle, UploadLifecycleStates.ACTIVE) - self.assertFalse(status.locked) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_get_upload_status_without_authorization(self): - """Get the status of an upload without the right scope.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - token = generate_token('1', 'u@ser.com', 'theuser', - scope=[scopes.WRITE_UPLOAD]) - data = fm.upload_package(pointer, self.token) - - with self.assertRaises(exceptions.RequestForbidden): - fm.get_upload_status(data.identifier, token) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_get_upload_status_nacho_upload(self): - """Get the status of someone elses' upload.""" - fm = Filemanager.current_session() - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - - data = fm.upload_package(pointer, self.token) - - token = generate_token('2', 'other@ser.com', 'theotheruser', - scope=[scopes.READ_UPLOAD]) - with self.assertRaises(exceptions.RequestForbidden): - fm.get_upload_status(data.identifier, token) - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_add_file_to_upload(self): - """Add a file to an existing upload workspace.""" - fm = Filemanager.current_session() - - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.zip') - pointer = FileStorage(open(fpath, 'rb'), filename='test.zip', - content_type='application/tar+gz') - data = fm.upload_package(pointer, self.token) - - fpath2 = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.txt') - pointer2 = FileStorage(open(fpath2, 'rb'), filename='test.txt', - content_type='text/plain') - - @mock.patch('arxiv.integration.api.service.current_app', mock_app) - def test_pdf_only_upload(self): - """Upload a PDF.""" - fm = Filemanager.current_session() - - fpath = os.path.join(os.path.split(os.path.abspath(__file__))[0], - 'data', 'test.pdf') - pointer = FileStorage(io.BytesIO(MINIMAL_PDF.encode('utf-8')), - filename='test.pdf', - content_type='application/pdf') - data = fm.upload_package(pointer, self.token) - upload_id = data.identifier - content, source_chex, file_chex = fm.get_single_file(upload_id, self.token) - self.assertEqual(source_chex, data.checksum) - self.assertEqual(len(content.read()), len(MINIMAL_PDF.encode('utf-8')), - 'Size of the original content is preserved') - self.assertEqual(file_chex, 'Copxu8SRHajXOfeK8_1h7w==') - - -# From https://brendanzagaeski.appspot.com/0004.html -MINIMAL_PDF = """ -%PDF-1.1 -%¥±ë - -1 0 obj - << /Type /Catalog - /Pages 2 0 R - >> -endobj - -2 0 obj - << /Type /Pages - /Kids [3 0 R] - /Count 1 - /MediaBox [0 0 300 144] - >> -endobj - -3 0 obj - << /Type /Page - /Parent 2 0 R - /Resources - << /Font - << /F1 - << /Type /Font - /Subtype /Type1 - /BaseFont /Times-Roman - >> - >> - >> - /Contents 4 0 R - >> -endobj - -4 0 obj - << /Length 55 >> -stream - BT - /F1 18 Tf - 0 0 Td - (Hello World) Tj - ET -endstream -endobj - -xref -0 5 -0000000000 65535 f -0000000018 00000 n -0000000077 00000 n -0000000178 00000 n -0000000457 00000 n -trailer - << /Root 1 0 R - /Size 5 - >> -startxref -565 -%%EOF -""" \ No newline at end of file diff --git a/src/arxiv/submission/services/plaintext/__init__.py b/src/arxiv/submission/services/plaintext/__init__.py deleted file mode 100644 index 5426f7c..0000000 --- a/src/arxiv/submission/services/plaintext/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Service integration module for plain text extraction.""" - -from .plaintext import PlainTextService, ExtractionFailed diff --git a/src/arxiv/submission/services/plaintext/plaintext.py b/src/arxiv/submission/services/plaintext/plaintext.py deleted file mode 100644 index cc0d361..0000000 --- a/src/arxiv/submission/services/plaintext/plaintext.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Provides integration with the plaintext extraction service. - -This integration is focused on usage patterns required by the submission -system. Specifically: - -1. Must be able to request an extraction for a compiled submission. -2. Must be able to poll whether the extraction has completed. -3. Must be able to retrieve the raw binary content from when the extraction - has finished successfully. -4. Encounter an informative exception if something goes wrong. - -This represents only a subset of the functionality provided by the plaintext -service itself. -""" - -from enum import Enum -from typing import Any, IO - -from arxiv.base import logging -from arxiv.integration.api import status, exceptions, service -from arxiv.taxonomy import Category - -from ..util import ReadWrapper - -logger = logging.getLogger(__name__) - - -class ExtractionFailed(exceptions.RequestFailed): - """The plain text extraction service failed to extract text.""" - - -class ExtractionInProgress(exceptions.RequestFailed): - """An extraction is already in progress.""" - - -class PlainTextService(service.HTTPIntegration): - """Represents an interface to the plain text extraction service.""" - - SERVICE = 'plaintext' - VERSION = '0.4.1rc1' - """Version of the service for which this module is implemented.""" - - class Meta: - """Configuration for :class:`Classifier`.""" - - service_name = "plaintext" - - class Status(Enum): - """Task statuses.""" - - IN_PROGRESS = 'in_progress' - SUCCEEDED = 'succeeded' - FAILED = 'failed' - - @property - def _base_endpoint(self) -> str: - return f'{self._scheme}://{self._host}:{self._port}' - - def is_available(self, **kwargs: Any) -> bool: - """Check our connection to the plain text service.""" - timeout: float = kwargs.get('timeout', 0.5) - try: - response = self.request('head', '/status', timeout=timeout) - except Exception as e: - logger.error('Encountered error calling plain text service: %s', e) - return False - if response.status_code != status.OK: - logger.error('Got unexpected status: %s', response.status_code) - return False - return True - - def endpoint(self, source_id: str, checksum: str) -> str: - """Get the URL of the extraction endpoint.""" - return f'/submission/{source_id}/{checksum}' - - def status_endpoint(self, source_id: str, checksum: str) -> str: - """Get the URL of the extraction status endpoint.""" - return f'/submission/{source_id}/{checksum}/status' - - def request_extraction(self, source_id: str, checksum: str, - token: str) -> None: - """ - Make a request for plaintext extraction using the source ID. - - Parameters - ---------- - source_id : str - ID of the submission upload workspace. - - """ - expected_code = [status.OK, status.ACCEPTED, - status.SEE_OTHER] - response = self.request('post', self.endpoint(source_id, checksum), - token, expected_code=expected_code) - if response.status_code == status.SEE_OTHER: - raise ExtractionInProgress('Extraction already exists', response) - elif response.status_code not in expected_code: - raise exceptions.RequestFailed('Unexpected status', response) - return - - def extraction_is_complete(self, source_id: str, checksum: str, - token: str) -> bool: - """ - Check the status of an extraction task by submission upload ID. - - Parameters - ---------- - source_id : str - ID of the submission upload workspace. - - Returns - ------- - bool - - Raises - ------ - :class:`ExtractionFailed` - Raised if the task is in a failed state, or an unexpected condition - is encountered. - - """ - endpoint = self.status_endpoint(source_id, checksum) - expected_code = [status.OK, status.SEE_OTHER] - response = self.request('get', endpoint, token, allow_redirects=False, - expected_code=expected_code) - data = response.json() - if response.status_code == status.SEE_OTHER: - return True - elif self.Status(data['status']) is self.Status.IN_PROGRESS: - return False - elif self.Status(data['status']) is self.Status.FAILED: - raise ExtractionFailed('Extraction failed', response) - raise ExtractionFailed('Unexpected state', response) - - def retrieve_content(self, source_id: str, checksum: str, - token: str) -> IO[bytes]: - """ - Retrieve plain text content by submission upload ID. - - Parameters - ---------- - source_id : str - ID of the submission upload workspace. - - Returns - ------- - :class:`io.BytesIO` - Raw content stream. - - Raises - ------ - :class:`RequestFailed` - Raised if an unexpected status was encountered. - :class:`ExtractionInProgress` - Raised if an extraction is currently in progress - - """ - expected_code = [status.OK, status.SEE_OTHER] - response = self.request('get', self.endpoint(source_id, checksum), - token, expected_code=expected_code, - headers={'Accept': 'text/plain'}) - if response.status_code == status.SEE_OTHER: - raise ExtractionInProgress('Extraction is in progress', response) - stream = ReadWrapper(response.iter_content, - int(response.headers['Content-Length'])) - return stream diff --git a/src/arxiv/submission/services/plaintext/tests.py b/src/arxiv/submission/services/plaintext/tests.py deleted file mode 100644 index 7349f23..0000000 --- a/src/arxiv/submission/services/plaintext/tests.py +++ /dev/null @@ -1,827 +0,0 @@ -"""Tests for :mod:`arxiv.submission.services.plaintext`.""" - -import io -import os -import tempfile -import time -from unittest import TestCase, mock -from threading import Thread - -import docker -from flask import Flask, send_file - -from arxiv.integration.api import exceptions, status -from ...tests.util import generate_token -from . import plaintext - - -class TestPlainTextService(TestCase): - """Tests for :class:`.plaintext.PlainTextService`.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'PLAINTEXT_ENDPOINT': 'http://foohost:5432', - 'PLAINTEXT_VERIFY': False - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_already_in_progress(self, mock_Session): - """A plaintext extraction is already in progress.""" - mock_post = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.SEE_OTHER, - json=mock.MagicMock(return_value={}), - headers={'Location': '...'} - ) - ) - mock_Session.return_value = mock.MagicMock(post=mock_post) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionInProgress): - service.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction(self, mock_Session): - """Extraction is successfully requested.""" - mock_session = mock.MagicMock(**{ - 'post': mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.ACCEPTED, - json=mock.MagicMock(return_value={}), - content='', - headers={'Location': '/somewhere'} - ) - ), - 'get': mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={'reason': 'extraction in process'} - ), - content="{'reason': 'fulltext extraction in process'}", - headers={} - ) - ) - }) - mock_Session.return_value = mock_session - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - self.assertIsNone( - service.request_extraction(source_id, 'foochex==', 'footoken') - ) - self.assertEqual( - mock_session.post.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==' - ) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction_bad_request(self, mock_Session): - """Service returns 400 Bad Request.""" - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.BAD_REQUEST, - json=mock.MagicMock(return_value={ - 'reason': 'something is not quite right' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.BadRequest): - service.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction_server_error(self, mock_Session): - """Service returns 500 Internal Server Error.""" - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.INTERNAL_SERVER_ERROR, - json=mock.MagicMock(return_value={ - 'reason': 'something is not quite right' - }) - ) - ) - ) - source_id = '132456' - - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestFailed): - service.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.UNAUTHORIZED, - json=mock.MagicMock(return_value={ - 'reason': 'who are you' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - service.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = mock.MagicMock( - post=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.FORBIDDEN, - json=mock.MagicMock(return_value={ - 'reason': 'you do not have sufficient authz' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - service.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_is_complete(self, mock_Session): - """Extraction is indeed complete.""" - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.SEE_OTHER, - json=mock.MagicMock(return_value={}), - headers={'Location': '...'} - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - self.assertTrue( - svc.extraction_is_complete(source_id, 'foochex==', 'footoken') - ) - self.assertEqual( - mock_get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==/status' - ) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_in_progress(self, mock_Session): - """Extraction is still in progress.""" - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value={'status': 'in_progress'}) - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - self.assertFalse( - svc.extraction_is_complete(source_id, 'foochex==', 'footoken') - ) - self.assertEqual( - mock_get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==/status' - ) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_failed(self, mock_Session): - """Extraction failed.""" - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock(return_value={'status': 'failed'}) - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionFailed): - svc.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_complete_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.UNAUTHORIZED, - json=mock.MagicMock(return_value={ - 'reason': 'who are you' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - svc.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_complete_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.FORBIDDEN, - json=mock.MagicMock(return_value={ - 'reason': 'you do not have sufficient authz' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - svc.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.UNAUTHORIZED, - json=mock.MagicMock(return_value={ - 'reason': 'who are you' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - svc.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = mock.MagicMock( - get=mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.FORBIDDEN, - json=mock.MagicMock(return_value={ - 'reason': 'you do not have sufficient authz' - }) - ) - ) - ) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - service.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve(self, mock_Session): - """Retrieval is successful.""" - content = b'thisisthecontent' - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - iter_content=lambda size: iter([content]) - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - svc = plaintext.PlainTextService.current_session() - rcontent = svc.retrieve_content(source_id, 'foochex==', 'footoken') - self.assertEqual(rcontent.read(), content, - "Returns binary content as received") - self.assertEqual(mock_get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_nonexistant(self, mock_Session): - """There is no such plaintext resource.""" - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.NOT_FOUND, - json=mock.MagicMock(return_value={'reason': 'no such thing'}) - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.NotFound): - service.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_in_progress(self, mock_Session): - """There is no such plaintext resource.""" - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.SEE_OTHER, - json=mock.MagicMock(return_value={}), - headers={'Location': '...'} - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - service = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionInProgress): - service.retrieve_content(source_id, 'foochex==', 'footoken') - - -class TestPlainTextServiceModule(TestCase): - """Tests for :mod:`.services.plaintext`.""" - - def setUp(self): - """Create an app for context.""" - self.app = Flask('test') - self.app.config.update({ - 'PLAINTEXT_ENDPOINT': 'http://foohost:5432', - 'PLAINTEXT_VERIFY': False - }) - - def session(self, status_code=status.OK, method="get", json={}, - content="", headers={}): - """Make a mock session.""" - return mock.MagicMock(**{ - method: mock.MagicMock( - return_value=mock.MagicMock( - status_code=status_code, - json=mock.MagicMock( - return_value=json - ), - iter_content=lambda size: iter([content]), - headers=headers - ) - ) - }) - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_already_in_progress(self, mock_Session): - """A plaintext extraction is already in progress.""" - mock_Session.return_value = self.session( - status_code=status.SEE_OTHER, - method='post', - headers={'Location': '...'} - ) - - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionInProgress): - pt.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction(self, mock_Session): - """Extraction is successfully requested.""" - mock_session = mock.MagicMock(**{ - 'post': mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.ACCEPTED, - json=mock.MagicMock(return_value={}), - content='', - headers={'Location': '/somewhere'} - ) - ), - 'get': mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - json=mock.MagicMock( - return_value={'reason': 'extraction in process'} - ), - content="{'reason': 'fulltext extraction in process'}", - headers={} - ) - ) - }) - mock_Session.return_value = mock_session - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertIsNone( - pt.request_extraction(source_id, 'foochex==', 'footoken') - ) - self.assertEqual(mock_session.post.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_bad_request(self, mock_Session): - """Service returns 400 Bad Request.""" - mock_Session.return_value = self.session( - status_code=status.BAD_REQUEST, - method='post', - json={'reason': 'something is not quite right'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.BadRequest): - pt.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_server_error(self, mock_Session): - """Service returns 500 Internal Server Error.""" - mock_Session.return_value = self.session( - status_code=status.INTERNAL_SERVER_ERROR, - method='post', - json={'reason': 'something is not quite right'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestFailed): - pt.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = self.session( - status_code=status.UNAUTHORIZED, - method='post', - json={'reason': 'who are you'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - pt.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_request_extraction_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = self.session( - status_code=status.FORBIDDEN, - method='post', - json={'reason': 'you do not have sufficient authz'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - pt.request_extraction(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_is_complete(self, mock_Session): - """Extraction is indeed complete.""" - mock_session = self.session( - status_code=status.SEE_OTHER, - headers={'Location': '...'} - ) - mock_Session.return_value = mock_session - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertTrue( - pt.extraction_is_complete(source_id, 'foochex==', 'footoken') - ) - self.assertEqual(mock_session.get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==/status') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_in_progress(self, mock_Session): - """Extraction is still in progress.""" - mock_session = self.session( - json={'status': 'in_progress'} - ) - mock_Session.return_value = mock_session - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertFalse( - pt.extraction_is_complete(source_id, 'foochex==', 'footoken') - ) - self.assertEqual(mock_session.get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==/status') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_extraction_failed(self, mock_Session): - """Extraction failed.""" - mock_Session.return_value = self.session(json={'status': 'failed'}) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionFailed): - pt.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_complete_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = self.session( - status_code=status.UNAUTHORIZED, - json={'reason': 'who are you'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - pt.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_complete_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = self.session( - status_code=status.FORBIDDEN, - json={'reason': 'you do not have sufficient authz'} - ) - source_id = '132456' - - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - pt.extraction_is_complete(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_unauthorized(self, mock_Session): - """Service returns 401 Unauthorized.""" - mock_Session.return_value = self.session( - status_code=status.UNAUTHORIZED, - json={'reason': 'who are you'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestUnauthorized): - pt.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_forbidden(self, mock_Session): - """Service returns 403 Forbidden.""" - mock_Session.return_value = self.session( - status_code=status.FORBIDDEN, - json={'reason': 'you do not have sufficient authz'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.RequestForbidden): - pt.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve(self, mock_Session): - """Retrieval is successful.""" - content = b'thisisthecontent' - mock_get = mock.MagicMock( - return_value=mock.MagicMock( - status_code=status.OK, - iter_content=lambda size: iter([content]) - ) - ) - mock_Session.return_value = mock.MagicMock(get=mock_get) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertEqual( - pt.retrieve_content(source_id, 'foochex==', 'footoken').read(), - content, - "Returns binary content as received" - ) - self.assertEqual(mock_get.call_args[0][0], - 'http://foohost:5432/submission/132456/foochex==') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_nonexistant(self, mock_Session): - """There is no such plaintext resource.""" - mock_Session.return_value = self.session( - status_code=status.NOT_FOUND, - json={'reason': 'no such thing'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(exceptions.NotFound): - pt.retrieve_content(source_id, 'foochex==', 'footoken') - - @mock.patch('arxiv.integration.api.service.requests.Session') - def test_retrieve_in_progress(self, mock_Session): - """There is no such plaintext resource.""" - mock_Session.return_value = self.session( - status_code=status.SEE_OTHER, - headers={'Location': '...'} - ) - source_id = '132456' - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - with self.assertRaises(plaintext.ExtractionInProgress): - pt.retrieve_content(source_id, 'foochex==', 'footoken') - - -class TestPlainTextServiceIntegration(TestCase): - """Integration tests for the plain text service.""" - - __test__ = bool(int(os.environ.get('WITH_INTEGRATION', '0'))) - - @classmethod - def setUpClass(cls): - """Start up the plain text service.""" - client = docker.from_env() - image = f'arxiv/{plaintext.PlainTextService.SERVICE}' - client.images.pull(image, tag=plaintext.PlainTextService.VERSION) - # client.images.pull('docker', tag='18-dind') - client.images.pull('redis') - - # Create a mock preview service, from which the plaintext service - # will retrieve a PDF. - cls.mock_preview = Flask('preview') - - # - @cls.mock_preview.route('///content', methods=['GET']) - def get_pdf(src, chx=None, fmt=None): - response = send_file(io.BytesIO(MINIMAL_PDF.encode('utf-8')), - mimetype='application/pdf') - response.headers['ARXIV-OWNER'] = src[0] - response.headers['ETag'] = 'footag==' - return response - - @cls.mock_preview.route('//', methods=['HEAD']) - @cls.mock_preview.route('///content', methods=['HEAD']) - def exists(src, chx=None, fmt=None): - return '', 200, {'ARXIV-OWNER': src[0], 'ETag': 'footag=='} - - def start_preview_app(): - cls.mock_preview.run('0.0.0.0', 5009) - - t = Thread(target=start_preview_app) - t.daemon = True - t.start() - - cls.network = client.networks.create('test-plaintext-network') - cls.data = client.volumes.create(name='data', driver='local') - - # This is the volume shared by the worker and the docker host. - cls.pdfs = tempfile.mkdtemp() - - cls.plaintext_api = client.containers.run( - f'{image}:{plaintext.PlainTextService.VERSION}', - detach=True, - network='test-plaintext-network', - ports={'8000/tcp': 8889}, - name='plaintext', - volumes={'data': {'bind': '/data', 'mode': 'rw'}, - cls.pdfs: {'bind': '/pdfs', 'mode': 'rw'}}, - environment={ - 'NAMESPACE': 'test', - 'REDIS_ENDPOINT': 'test-plaintext-redis:6379', - 'PREVIEW_ENDPOINT': 'http://host.docker.internal:5009', - 'JWT_SECRET': 'foosecret', - 'MOUNTDIR': cls.pdfs - }, - command=["uwsgi", "--ini", "/opt/arxiv/uwsgi.ini"] - ) - cls.redis = client.containers.run( - f'redis', - detach=True, - network='test-plaintext-network', - name='test-plaintext-redis' - ) - cls.plaintext_worker = client.containers.run( - f'{image}:{plaintext.PlainTextService.VERSION}', - detach=True, - network='test-plaintext-network', - volumes={'data': {'bind': '/data', 'mode': 'rw'}, - cls.pdfs: {'bind': '/pdfs', 'mode': 'rw'}, - '/var/run/docker.sock': {'bind': '/var/run/docker.sock', 'mode': 'rw'}}, - environment={ - 'NAMESPACE': 'test', - 'REDIS_ENDPOINT': 'test-plaintext-redis:6379', - 'DOCKER_HOST': 'unix://var/run/docker.sock', - 'PREVIEW_ENDPOINT': 'http://host.docker.internal:5009', - 'JWT_SECRET': 'foosecret', - 'MOUNTDIR': cls.pdfs - }, - command=["celery", "worker", "-A", "fulltext.worker.celery_app", - "--loglevel=INFO", "-E", "--concurrency=1"] - ) - time.sleep(5) - - cls.app = Flask('test') - cls.app.config.update({ - 'PLAINTEXT_SERVICE_HOST': 'localhost', - 'PLAINTEXT_SERVICE_PORT': '8889', - 'PLAINTEXT_PORT_8889_PROTO': 'http', - 'PLAINTEXT_VERIFY': False, - 'PLAINTEXT_ENDPOINT': 'http://localhost:8889', - 'JWT_SECRET': 'foosecret' - }) - cls.token = generate_token(cls.app, ['fulltext:create', 'fulltext:read']) - plaintext.PlainTextService.init_app(cls.app) - - @classmethod - def tearDownClass(cls): - """Tear down the plain text service.""" - cls.plaintext_api.kill() - cls.plaintext_api.remove() - cls.plaintext_worker.kill() - cls.plaintext_worker.remove() - cls.redis.kill() - cls.redis.remove() - cls.data.remove() - cls.network.remove() - - def test_get_status(self): - """Get the status endpoint.""" - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertEqual(pt.get_status(), - {'extractor': True, 'storage': True}) - - def test_is_available(self): - """Poll for availability.""" - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertTrue(pt.is_available()) - - def test_extraction(self): - """Request, poll, and retrieve a plain text extraction.""" - with self.app.app_context(): - pt = plaintext.PlainTextService.current_session() - self.assertIsNone( - pt.request_extraction('1234', 'foochex', self.token) - ) - tries = 0 - while not pt.extraction_is_complete('1234', 'foochex', self.token): - print('waiting for extraction to complete:', tries) - tries += 1 - time.sleep(5) - if tries > 20: - self.fail('waited too long') - print('done') - content = pt.retrieve_content('1234', 'foochex', self.token) - self.assertEqual(content.read().strip(), b'Hello World') - - -# From https://brendanzagaeski.appspot.com/0004.html -MINIMAL_PDF = """ -%PDF-1.1 -%¥±ë - -1 0 obj - << /Type /Catalog - /Pages 2 0 R - >> -endobj - -2 0 obj - << /Type /Pages - /Kids [3 0 R] - /Count 1 - /MediaBox [0 0 300 144] - >> -endobj - -3 0 obj - << /Type /Page - /Parent 2 0 R - /Resources - << /Font - << /F1 - << /Type /Font - /Subtype /Type1 - /BaseFont /Times-Roman - >> - >> - >> - /Contents 4 0 R - >> -endobj - -4 0 obj - << /Length 55 >> -stream - BT - /F1 18 Tf - 0 0 Td - (Hello World) Tj - ET -endstream -endobj - -xref -0 5 -0000000000 65535 f -0000000018 00000 n -0000000077 00000 n -0000000178 00000 n -0000000457 00000 n -trailer - << /Root 1 0 R - /Size 5 - >> -startxref -565 -%%EOF -""" \ No newline at end of file diff --git a/src/arxiv/submission/services/preview/__init__.py b/src/arxiv/submission/services/preview/__init__.py deleted file mode 100644 index 4a5a635..0000000 --- a/src/arxiv/submission/services/preview/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Integration with the submission preview service.""" - -from .preview import PreviewService \ No newline at end of file diff --git a/src/arxiv/submission/services/preview/preview.py b/src/arxiv/submission/services/preview/preview.py deleted file mode 100644 index 0a9789d..0000000 --- a/src/arxiv/submission/services/preview/preview.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Integration with the submission preview service.""" - -import io -from datetime import datetime -from http import HTTPStatus as status -from typing import Tuple, Any, IO, Callable, Iterator, Optional, Literal -from urllib3.util.retry import Retry - -from backports.datetime_fromisoformat import MonkeyPatch -from mypy_extensions import TypedDict - -from arxiv.base import logging -from arxiv.integration.api import service, exceptions - -from ...domain.preview import Preview -from ..util import ReadWrapper - - -MonkeyPatch.patch_fromisoformat() -logger = logging.getLogger(__name__) - - -class AlreadyExists(exceptions.BadRequest): - """An attempt was made to deposit a preview that already exists.""" - - -class PreviewMeta(TypedDict): - added: str - size_bytes: int - checksum: str - - -class PreviewService(service.HTTPIntegration): - """Represents an interface to the submission preview.""" - - VERSION = '17057e6' - SERVICE = 'preview' - - class Meta: - """Configuration for :class:`PreviewService` integration.""" - - service_name = 'preview' - - def get_retry_config(self) -> Retry: - """ - Configure to only retry on connection errors. - - We are likely to be sending non-seakable streams, so retry should be - handled at the application level. - """ - return Retry( - total=10, - read=0, - connect=10, - status=0, - backoff_factor=0.5 - ) - - def is_available(self, **kwargs: Any) -> bool: - """Check our connection to the filesystem service.""" - timeout: float = kwargs.get('timeout', 0.2) - try: - response = self.request('head', '/status', timeout=timeout) - except Exception as e: - logger.error('Encountered error calling filesystem: %s', e) - return False - return bool(response.status_code == status.OK) - - def get(self, source_id: int, checksum: str, token: str) \ - -> Tuple[IO[bytes], str]: - """ - Retrieve the content of the PDF preview for a submission. - - Parameters - ---------- - source_id : int - Unique identifier of the source package from which the preview was - generated. - checksum : str - URL-safe base64-encoded MD5 hash of the source package content. - token : str - Authnz token for the request. - - Returns - ------- - :class:`io.BytesIO` - Streaming content of the preview. - str - URL-safe base64-encoded MD5 hash of the preview content. - - """ - response = self.request('get', f'/{source_id}/{checksum}/content', - token) - preview_checksum = str(response.headers['ETag']) - stream = ReadWrapper(response.iter_content, - int(response.headers['Content-Length'])) - return stream, preview_checksum - - def get_metadata(self, source_id: int, checksum: str, token: str) \ - -> Preview: - """ - Retrieve metadata about a preview. - - Parameters - ---------- - source_id : int - Unique identifier of the source package from which the preview was - generated. - checksum : str - URL-safe base64-encoded MD5 hash of the source package content. - token : str - Authnz token for the request. - - Returns - ------- - :class:`.Preview` - - """ - response = self.request('get', f'/{source_id}/{checksum}', token) - response_data: PreviewMeta = response.json() - # fromisoformat() is backported from 3.7. - added: datetime = datetime.fromisoformat(response_data['added']) # type: ignore - return Preview(source_id=source_id, - source_checksum=checksum, - preview_checksum=response_data['checksum'], - added=added, - size_bytes=response_data['size_bytes']) - - def deposit(self, source_id: int, checksum: str, stream: IO[bytes], - token: str, overwrite: bool = False, - content_checksum: Optional[str] = None) -> Preview: - """ - Deposit a preview. - - Parameters - ---------- - source_id : int - Unique identifier of the source package from which the preview was - generated. - checksum : str - URL-safe base64-encoded MD5 hash of the source package content. - stream : :class:`.io.BytesIO` - Streaming content of the preview. - token : str - Authnz token for the request. - overwrite : bool - If ``True``, any existing preview will be overwritten. - - Returns - ------- - :class:`.Preview` - - Raises - ------ - :class:`AlreadyExists` - Raised when ``overwrite`` is ``False`` and a preview already exists - for the provided ``source_id`` and ``checksum``. - - """ - headers = {'Overwrite': 'true' if overwrite else 'false'} - if content_checksum is not None: - headers['ETag'] = content_checksum - - # print('here is what we are about to put to the preview service') - # raw_content = stream.read() - # print('data length:: ', len(raw_content)) - - try: - response = self.request('put', f'/{source_id}/{checksum}/content', - token, data=stream, #io.BytesIO(raw_content), #stream - headers=headers, - expected_code=[status.CREATED], - allow_2xx_redirects=False) - except exceptions.BadRequest as e: - if e.response.status_code == status.CONFLICT: - raise AlreadyExists('Preview already exists', e.response) from e - raise - response_data: PreviewMeta = response.json() - # fromisoformat() is backported from 3.7. - added: datetime = datetime.fromisoformat(response_data['added']) # type: ignore - return Preview(source_id=source_id, - source_checksum=checksum, - preview_checksum=response_data['checksum'], - added=added, - size_bytes=response_data['size_bytes']) - - def has_preview(self, source_id: int, checksum: str, token: str, - content_checksum: Optional[str] = None) -> bool: - """ - Check whether a preview exists for a specific source package. - - Parameters - ---------- - source_id : int - Unique identifier of the source package from which the preview was - generated. - checksum : str - URL-safe base64-encoded MD5 hash of the source package content. - token : str - Authnz token for the request. - content_checksum : str or None - URL-safe base64-encoded MD5 hash of the preview content. If - provided, will return ``True`` only if this value matches the - value of the ``ETag`` header returned by the preview service. - - Returns - ------- - bool - - """ - try: - response = self.request('head', f'/{source_id}/{checksum}', token) - except exceptions.NotFound: - return False - if content_checksum is not None: - if response.headers.get('ETag') != content_checksum: - return False - return True diff --git a/src/arxiv/submission/services/preview/tests.py b/src/arxiv/submission/services/preview/tests.py deleted file mode 100644 index 34414c3..0000000 --- a/src/arxiv/submission/services/preview/tests.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Integration tests for the preview service.""" - -import io -import os -import time -from unittest import TestCase -from flask import Flask -import docker - -from .preview import PreviewService, AlreadyExists, exceptions - - -class TestPreviewIntegration(TestCase): - """Integration tests for the preview service module.""" - - __test__ = bool(int(os.environ.get('WITH_INTEGRATION', '0'))) - - @classmethod - def setUpClass(cls): - """Start up the preview service, backed by localstack S3.""" - client = docker.from_env() - image = f'arxiv/{PreviewService.SERVICE}' - client.images.pull(image, tag=PreviewService.VERSION) - cls.network = client.networks.create('test-preview-network') - cls.localstack = client.containers.run( - 'atlassianlabs/localstack', - detach=True, - ports={'4572/tcp': 5572}, - network='test-preview-network', - name='localstack', - environment={'USE_SSL': 'true'} - ) - cls.container = client.containers.run( - f'{image}:{PreviewService.VERSION}', - detach=True, - network='test-preview-network', - ports={'8000/tcp': 8889}, - environment={'S3_ENDPOINT': 'https://localstack:4572', - 'S3_VERIFY': '0', - 'NAMESPACE': 'test'} - ) - time.sleep(5) - - cls.app = Flask('test') - cls.app.config.update({ - 'PREVIEW_SERVICE_HOST': 'localhost', - 'PREVIEW_SERVICE_PORT': '8889', - 'PREVIEW_PORT_8889_PROTO': 'http', - 'PREVIEW_VERIFY': False, - 'PREVIEW_ENDPOINT': 'http://localhost:8889' - - }) - PreviewService.init_app(cls.app) - - @classmethod - def tearDownClass(cls): - """Tear down the preview service and localstack.""" - cls.container.kill() - cls.container.remove() - cls.localstack.kill() - cls.localstack.remove() - cls.network.remove() - - def test_get_status(self): - """Get the status endpoint.""" - with self.app.app_context(): - pv = PreviewService.current_session() - self.assertEqual(pv.get_status(), {'iam': 'ok'}) - - def test_is_available(self): - """Poll for availability.""" - with self.app.app_context(): - pv = PreviewService.current_session() - self.assertTrue(pv.is_available()) - - def test_deposit_retrieve(self): - """Deposit and retrieve a preview.""" - with self.app.app_context(): - pv = PreviewService.current_session() - content = io.BytesIO(b'foocontent') - source_id = 1234 - checksum = 'foochex==' - token = 'footoken' - preview = pv.deposit(source_id, checksum, content, token) - self.assertEqual(preview.source_id, 1234) - self.assertEqual(preview.source_checksum, 'foochex==') - self.assertEqual(preview.preview_checksum, - 'ewrggAHdCT55M1uUfwKLEA==') - self.assertEqual(preview.size_bytes, 10) - - stream, preview_checksum = pv.get(source_id, checksum, token) - self.assertEqual(stream.read(), b'foocontent') - self.assertEqual(preview_checksum, preview.preview_checksum) - - def test_deposit_conflict(self): - """Deposit the same preview twice.""" - with self.app.app_context(): - pv = PreviewService.current_session() - content = io.BytesIO(b'foocontent') - source_id = 1235 - checksum = 'foochex==' - token = 'footoken' - preview = pv.deposit(source_id, checksum, content, token) - self.assertEqual(preview.source_id, 1235) - self.assertEqual(preview.source_checksum, 'foochex==') - self.assertEqual(preview.preview_checksum, - 'ewrggAHdCT55M1uUfwKLEA==') - self.assertEqual(preview.size_bytes, 10) - - with self.assertRaises(AlreadyExists): - pv.deposit(source_id, checksum, content, token) - - def test_deposit_conflict_force(self): - """Deposit the same preview twice and explicitly overwrite.""" - with self.app.app_context(): - pv = PreviewService.current_session() - content = io.BytesIO(b'foocontent') - source_id = 1236 - checksum = 'foochex==' - token = 'footoken' - preview = pv.deposit(source_id, checksum, content, token) - self.assertEqual(preview.source_id, 1236) - self.assertEqual(preview.source_checksum, 'foochex==') - self.assertEqual(preview.preview_checksum, - 'ewrggAHdCT55M1uUfwKLEA==') - self.assertEqual(preview.size_bytes, 10) - - content = io.BytesIO(b'barcontent') - preview = pv.deposit(source_id, checksum, content, token, - overwrite=True) - self.assertEqual(preview.source_id, 1236) - self.assertEqual(preview.source_checksum, 'foochex==') - self.assertEqual(preview.preview_checksum, - 'uW94u_u4xfDA3lcVd354ng==') - self.assertEqual(preview.size_bytes, 10) - - def get_nonexistant_preview(self): - """Try to get a non-existant preview.""" - with self.app.app_context(): - pv = PreviewService.current_session() - with self.assertRaises(exceptions.NotFound): - pv.get(9876, 'foochex==', 'footoken') \ No newline at end of file diff --git a/src/arxiv/submission/services/stream/__init__.py b/src/arxiv/submission/services/stream/__init__.py deleted file mode 100644 index f7fcf5b..0000000 --- a/src/arxiv/submission/services/stream/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Emits events to the submission stream.""" - -from .stream import StreamPublisher diff --git a/src/arxiv/submission/services/stream/stream.py b/src/arxiv/submission/services/stream/stream.py deleted file mode 100644 index 56ed6e8..0000000 --- a/src/arxiv/submission/services/stream/stream.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Provides the stream publishing integration.""" - -from typing import Optional, Any - -import boto3 -from botocore.exceptions import ClientError -from retry import retry - -from arxiv.base import logging -from arxiv.base.globals import get_application_config, get_application_global -from arxiv.integration.meta import MetaIntegration - -from ...domain import Submission, Event -from ...serializer import dumps - -logger = logging.getLogger(__name__) - - -class StreamPublisher(metaclass=MetaIntegration): - def __init__(self, stream: str, partition_key: str, - aws_access_key_id: str, aws_secret_access_key: str, - region_name: str, endpoint_url: Optional[str] = None, - verify: bool = True) -> None: - self.stream = stream - self.partition_key = partition_key - self.client = boto3.client('kinesis', - region_name=region_name, - endpoint_url=endpoint_url, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - verify=verify) - - @classmethod - def init_app(cls, app: object = None) -> None: - """Set default configuration params for an application instance.""" - config = get_application_config(app) - config.setdefault('AWS_ACCESS_KEY_ID', '') - config.setdefault('AWS_SECRET_ACCESS_KEY', '') - config.setdefault('AWS_REGION', 'us-east-1') - config.setdefault('KINESIS_ENDPOINT', None) - config.setdefault('KINESIS_VERIFY', True) - config.setdefault('KINESIS_STREAM', 'SubmissionEvents') - config.setdefault('KINESIS_PARTITION_KEY', '0') - - @classmethod - def get_session(cls, app: object = None) -> 'StreamPublisher': - """Get a new session with the stream.""" - config = get_application_config(app) - aws_access_key_id = config['AWS_ACCESS_KEY_ID'] - aws_secret_access_key = config['AWS_SECRET_ACCESS_KEY'] - aws_region = config['AWS_REGION'] - kinesis_endpoint = config['KINESIS_ENDPOINT'] - kinesis_verify = config['KINESIS_VERIFY'] - kinesis_stream = config['KINESIS_STREAM'] - partition_key = config['KINESIS_PARTITION_KEY'] - return cls(kinesis_stream, partition_key, aws_access_key_id, - aws_secret_access_key, aws_region, kinesis_endpoint, - kinesis_verify) - - @classmethod - def current_session(cls) -> 'StreamPublisher': - """Get/create :class:`.StreamPublisher` for this context.""" - g = get_application_global() - if not g: - return cls.get_session() - elif 'stream' not in g: - g.stream = cls.get_session() # type: ignore - return g.stream # type: ignore - - def is_available(self, **kwargs: Any) -> bool: - """Test our ability to put records.""" - data = bytes(dumps({}), encoding='utf-8') - try: - self.client.put_record(StreamName=self.stream, Data=data, - PartitionKey=self.partition_key) - except Exception as e: - logger.error('Encountered error while putting to stream: %s', e) - return False - return True - - def _create_stream(self) -> None: - try: - self.client.create_stream(StreamName=self.stream, ShardCount=1) - except self.client.exceptions.ResourceInUseException: - logger.info('Stream %s already exists', self.stream) - return - - def _wait_for_stream(self, retries: int = 0, delay: int = 0) -> None: - waiter = self.client.get_waiter('stream_exists') - waiter.wait( - StreamName=self.stream, - WaiterConfig={ - 'Delay': delay, - 'MaxAttempts': retries - } - ) - - @retry(RuntimeError, tries=5, delay=2, backoff=2) - def initialize(self) -> None: - """Perform initial checks, e.g. at application start-up.""" - logger.info('initialize Kinesis stream') - data = bytes(dumps({}), encoding='utf-8') - try: - self.client.put_record(StreamName=self.stream, Data=data, - PartitionKey=self.partition_key) - logger.info('storage service is already available') - except ClientError as exc: - if exc.response['Error']['Code'] == 'ResourceNotFoundException': - logger.info('stream does not exist; creating') - self._create_stream() - logger.info('wait for stream to be available') - self._wait_for_stream(retries=10, delay=5) - raise RuntimeError('Failed to initialize stream') from exc - except self.client.exceptions.ResourceNotFoundException: - logger.info('stream does not exist; creating') - self._create_stream() - logger.info('wait for stream to be available') - self._wait_for_stream(retries=10, delay=5) - except Exception as exc: - raise RuntimeError('Failed to initialize stream') from exc - return - - def put(self, event: Event, before: Submission, after: Submission) -> None: - """Put an :class:`.Event` on the stream.""" - payload = {'event': event, 'before': before, 'after': after} - data = bytes(dumps(payload), encoding='utf-8') - self.client.put_record(StreamName=self.stream, Data=data, - PartitionKey=self.partition_key) diff --git a/src/arxiv/submission/services/util.py b/src/arxiv/submission/services/util.py deleted file mode 100644 index eb277af..0000000 --- a/src/arxiv/submission/services/util.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Helpers for service modules.""" - -import io -from typing import Callable, Iterator, Any, Optional, Literal - - - -class ReadWrapper(io.BytesIO): - """Wraps a response body streaming iterator to provide ``read()``.""" - - def __init__(self, iter_content: Callable[[int], Iterator[bytes]], - content_size_bytes: int, size: int = 4096) -> None: - """Initialize the streaming iterator.""" - self._iter_content = iter_content(size) - # Must be set for requests to treat this as streamable "file like - # object". - # See https://github.com/psf/requests/blob/bedd9284c9646e50c10b3defdf519d4ba479e2c7/requests/models.py#L476 - self.len = content_size_bytes - - def seekable(self) -> Literal[False]: - """Indicate that this is a non-seekable stream.""" - return False - - def readable(self) -> Literal[True]: - """Indicate that it *is* a readable stream.""" - return True - - def read(self, *args: Any, **kwargs: Any) -> bytes: - """ - Read the next chunk of the content stream. - - Arguments are ignored, since the chunk size must be set at the start. - """ - # print('read with size', size) - # if size == -1: - # return b''.join(self._iter_content) - return next(self._iter_content) - - def __len__(self) -> int: - return self.len - - # This must be included for requests to treat this as a streamble - # "file-like object". - # See https://github.com/psf/requests/blob/bedd9284c9646e50c10b3defdf519d4ba479e2c7/requests/models.py#L470-L473 - def __iter__(self) -> Iterator[bytes]: - """Generate chunks of body content.""" - return self._iter_content diff --git a/src/arxiv/submission/templates/submission-core/confirmation-email.html b/src/arxiv/submission/templates/submission-core/confirmation-email.html deleted file mode 100644 index 364ad7e..0000000 --- a/src/arxiv/submission/templates/submission-core/confirmation-email.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "mail/base.html" %} - -{% block email_title %}arXiv submission submit/{{ submission_id }}{% endblock email_title %} - -{% block message_title %}We have received your submission to arXiv, titled "{{ submission.metadata.title }}"{% endblock message_title %} - -{% block message_body %} -

- Your temporary submission identifier is: submit/{{ submission_id }}. - To preview your submission, check the - submission status page. -

- -

- Your article is scheduled to be announced at {{ announce_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }}. The abstract - will appear in the subsequent mailing as displayed below, except that the - submission identifier will be replaced by the official arXiv identifier. - Updates before {{ freeze_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }} will not delay announcement. -

- -

- A paper password will be emailed to you when the article is announced. You - should share this with co-authors to allow them to claim ownership. If you - have a problem that you are not able to resolve through the web interface, - contact {{ config.SUPPORT_EMAIL }} with a - description of the issue and reference the submission identifier. -

-{% endblock message_body %} diff --git a/src/arxiv/submission/templates/submission-core/confirmation-email.txt b/src/arxiv/submission/templates/submission-core/confirmation-email.txt deleted file mode 100644 index 9db073c..0000000 --- a/src/arxiv/submission/templates/submission-core/confirmation-email.txt +++ /dev/null @@ -1,38 +0,0 @@ -{% import "base/macros.html" as macros %} - -We have received your submission to arXiv. - -Your temporary submission identifier is: submit/{{ submission_id }}. You may -update your submission at: {{ url_for("submission", -submission_id=submission_id) }}. - -Your article is scheduled to be announced at {{ announce_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }}. The -abstract will appear in the subsequent mailing as displayed below, except that -the submission identifier will be replaced by the official arXiv identifier. -Updates before {{ freeze_time.strftime("%a, %-d %b %Y %H:%M:%S ET") }} will not delay announcement. - -A paper password will be emailed to you when the article is announced. You -should share this with co-authors to allow them to claim ownership. If you have -a problem that you are not able to resolve through the web interface, contact -{{ config.SUPPORT_EMAIL }} with a description of the issue and reference the -submission identifier. - -{{ macros.abs_plaintext( - arxiv_id, - submission.metadata.title, - submission.metadata.authors_display, - submission.metadata.abstract, - submission.created, - submission.primary_classification.category, - submission.creator.name, - submission.creator.email, - submission.source_content.uncompressed_size, - submission.license.uri, - comments = submission.metadata.comments, - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = submission.metadata.journal_ref, - report_num = submission.metadata.report_num, - version = submission.version, - submission_history = [], - secondary_categories = submission.secondary_categories) }} diff --git a/src/arxiv/submission/tests/#util.py# b/src/arxiv/submission/tests/#util.py# deleted file mode 100644 index 15868ba..0000000 --- a/src/arxiv/submission/tests/#util.py# +++ /dev/null @@ -1,74 +0,0 @@ -import uuid -from contextlib import contextmanager -from datetime import datetime, timedelta -from typing import Optional, List - -from flask import Flask -from pytz import UTC - -from arxiv.users import domain, auth - -from ..services import classic - - -@contextmanager -def in_memory_db(app: Optional[Flask] = None): - """Provide an in-memory sqlite database for testing purposes.""" - if app is None: - app = Flask('foo') - app.config['CLASSIC_DATABASE_URI'] = 'sqlite://' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with app.app_context(): - classic.init_app(app) - classic.create_all() - try: - yield classic.current_session() - except Exception: - raise - finally: - classic.drop_all() - - -# Generate authentication token -def generate_token(app: Flask, scope: List[str]) -> str: - """Helper function for generating a JWT.""" - secret = app.config.get('JWT_SECRET') - start = datetime.now(tz=UTC) - end = start + timedelta(seconds=36000) # Make this as long as you want. - user_id = '1' - email = 'foo@bar.com' - username = 'theuser' - first_name = 'Jane' - last_name = 'Doe' - suffix_name = 'IV' - affiliation = 'Cornell University' - rank = 3 - country = 'us' - default_category = 'astro-ph.GA' - submission_groups = 'grp_physics' - endorsements = 'astro-ph.CO,astro-ph.GA' - session = domain.Session( - session_id=str(uuid.uuid4()), - start_time=start, end_time=end, - user=domain.User( - user_id=user_id, - email=email, - username=username, - name=domain.UserFullName(first_name, last_name, suffix_name), - profile=domain.UserProfile( - affiliation=affiliation, - rank=int(rank), - country=country, - default_category=domain.Category(default_category), - submission_groups=submission_groups.split(',') - ) - ), - authorizations=domain.Authorizations( - scopes=scope, - endorsements=[domain.Category(cat.split('.', 1)) - for cat in endorsements.split(',')] - ) - ) - token = auth.tokens.encode(session, secret) - return token \ No newline at end of file diff --git a/src/arxiv/submission/tests/__init__.py b/src/arxiv/submission/tests/__init__.py deleted file mode 100644 index 53d76ad..0000000 --- a/src/arxiv/submission/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package-level tests for :mod:`events`.""" diff --git a/src/arxiv/submission/tests/api/test_api.py b/src/arxiv/submission/tests/api/test_api.py deleted file mode 100644 index 563d32c..0000000 --- a/src/arxiv/submission/tests/api/test_api.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Tests for :mod:`events` public API.""" - -from unittest import TestCase, mock -import os -from collections import defaultdict -from datetime import datetime, timedelta -from flask import Flask -from pytz import UTC -from ... import save, load, core, Submission, User, Event, \ - SubmissionMetadata -from ...domain.event import CreateSubmission, SetAuthors, Author, \ - SetTitle, SetAbstract -from ...exceptions import NoSuchSubmission, InvalidEvent -from ...services import classic - - -def mock_store_event(event, before, after, emit): - event.submission_id = 1 - after.submission_id = 1 - event.committed = True - emit(event) - return event, after - - -class TestLoad(TestCase): - """Test :func:`.load`.""" - - @mock.patch('submission.core.classic') - def test_load_existant_submission(self, mock_classic): - """When the submission exists, submission and events are returned.""" - u = User(12345, 'joe@joe.joe') - mock_classic.get_submission.return_value = ( - Submission(creator=u, submission_id=1, owner=u, - created=datetime.now(UTC)), - [CreateSubmission(creator=u, submission_id=1, committed=True)] - ) - submission, events = load(1) - self.assertEqual(mock_classic.get_submission.call_count, 1) - self.assertIsInstance(submission, Submission, - "A submission should be returned") - self.assertIsInstance(events, list, - "A list of events should be returned") - self.assertIsInstance(events[0], Event, - "A list of events should be returned") - - @mock.patch('submission.core.classic') - def test_load_nonexistant_submission(self, mock_classic): - """When the submission does not exist, an exception is raised.""" - mock_classic.get_submission.side_effect = classic.NoSuchSubmission - mock_classic.NoSuchSubmission = classic.NoSuchSubmission - with self.assertRaises(NoSuchSubmission): - load(1) - - -class TestSave(TestCase): - """Test :func:`.save`.""" - - @mock.patch(f'{core.__name__}.StreamPublisher') - @mock.patch('submission.core.classic') - def test_save_creation_event(self, mock_database, mock_publisher): - """A :class:`.CreationEvent` is passed.""" - mock_database.store_event = mock_store_event - mock_database.exceptions = classic.exceptions - user = User(12345, 'joe@joe.joe') - event = CreateSubmission(creator=user) - submission, events = save(event) - self.assertIsInstance(submission, Submission, - "A submission instance should be returned") - self.assertIsInstance(events[0], Event, - "Should return a list of events") - self.assertEqual(events[0], event, - "The first event should be the event that was passed") - self.assertIsNotNone(submission.submission_id, - "Submission ID should be set.") - - self.assertEqual(mock_publisher.put.call_count, 1) - args = event, None, submission - self.assertTrue(mock_publisher.put.called_with(*args)) - - @mock.patch(f'{core.__name__}.StreamPublisher') - @mock.patch('submission.core.classic') - def test_save_events_from_scratch(self, mock_database, mock_publisher): - """Save multiple events for a nonexistant submission.""" - mock_database.store_event = mock_store_event - mock_database.exceptions = classic.exceptions - user = User(12345, 'joe@joe.joe') - e = CreateSubmission(creator=user) - e2 = SetTitle(creator=user, title='footitle') - submission, events = save(e, e2) - - self.assertEqual(submission.metadata.title, 'footitle') - self.assertIsInstance(submission.submission_id, int) - self.assertEqual(submission.created, e.created) - - self.assertEqual(mock_publisher.put.call_count, 2) - self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) - self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) - - @mock.patch(f'{core.__name__}.StreamPublisher') - @mock.patch('submission.core.classic') - def test_create_and_update_authors(self, mock_database, mock_publisher): - """Save multiple events for a nonexistant submission.""" - mock_database.store_event = mock_store_event - mock_database.exceptions = classic.exceptions - user = User(12345, 'joe@joe.joe') - e = CreateSubmission(creator=user) - e2 = SetAuthors(creator=user, authors=[ - Author(0, forename='Joe', surname="Bloggs", email="joe@blog.gs") - ]) - submission, events = save(e, e2) - self.assertIsInstance(submission.metadata.authors[0], Author) - - self.assertEqual(mock_publisher.put.call_count, 2) - self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) - self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - @mock.patch('submission.core.classic') - def test_save_from_scratch_without_creation_event(self, mock_database): - """An exception is raised when there is no creation event.""" - mock_database.store_event = mock_store_event - user = User(12345, 'joe@joe.joe') - e2 = SetTitle(creator=user, title='foo') - with self.assertRaises(NoSuchSubmission): - save(e2) - - @mock.patch(f'{core.__name__}.StreamPublisher') - @mock.patch('submission.core.classic') - def test_save_events_on_existing_submission(self, mock_db, mock_publisher): - """Save multiple sets of events in separate calls to :func:`.save`.""" - mock_db.exceptions = classic.exceptions - cache = {} - - def mock_store_event_with_cache(event, before, after, emit): - if after.submission_id is None: - if before is not None: - before.submission_id = 1 - after.submission_id = 1 - - event.committed = True - event.submission_id = after.submission_id - if event.submission_id not in cache: - cache[event.submission_id] = (None, []) - cache[event.submission_id] = ( - after, cache[event.submission_id][1] + [event] - ) - emit(event) - return event, after - - def mock_get_events(submission_id, *args, **kwargs): - return cache[submission_id] - - mock_db.store_event = mock_store_event_with_cache - mock_db.get_submission = mock_get_events - - # Here is the first set of events. - user = User(12345, 'joe@joe.joe') - e = CreateSubmission(creator=user) - e2 = SetTitle(creator=user, title='footitle') - submission, _ = save(e, e2) - submission_id = submission.submission_id - - # Now we apply a second set of events. - e3 = SetAbstract(creator=user, abstract='bar'*10) - submission2, _ = save(e3, submission_id=submission_id) - - # The submission state reflects all three events. - self.assertEqual(submission2.metadata.abstract, 'bar'*10, - "State of the submission should reflect both sets" - " of events.") - self.assertEqual(submission2.metadata.title, 'footitle', - "State of the submission should reflect both sets" - " of events.") - self.assertEqual(submission2.created, e.created, - "The creation date of the submission should be the" - " original creation date.") - self.assertEqual(submission2.submission_id, submission_id, - "The submission ID should remain the same.") - - self.assertEqual(mock_publisher.put.call_count, 3) - self.assertEqual(mock_publisher.put.mock_calls[0][1][0], e) - self.assertEqual(mock_publisher.put.mock_calls[1][1][0], e2) - self.assertEqual(mock_publisher.put.mock_calls[2][1][0], e3) diff --git a/src/arxiv/submission/tests/classic/test_classic_integration.py b/src/arxiv/submission/tests/classic/test_classic_integration.py deleted file mode 100644 index f8bb92e..0000000 --- a/src/arxiv/submission/tests/classic/test_classic_integration.py +++ /dev/null @@ -1,1064 +0,0 @@ -""" -Tests for integration with the classic system. - -Provides test cases for the new events model's ability to replicate the classic -model. The function `TestClassicUIWorkflow.test_classic_workflow()` provides -keyword arguments to pass different types of data through the workflow. - -TODO: Presently, `test_classic_workflow` expects `core.domain` objects. That -should change to instantiate each object at runtime for database imports. -""" - -from unittest import TestCase, mock -from datetime import datetime -import tempfile -from pytz import UTC -from flask import Flask - -from arxiv.base import Base -from arxiv import mail -from ..util import in_memory_db -from ... import domain -from ...domain.event import * -from ... import * -from ...services import classic - - -# class TestClassicUIWorkflow(TestCase): -# """Replicate the classic submission UI workflow.""" - -# def setUp(self): -# """An arXiv user is submitting a new paper.""" -# self.app = Flask(__name__) -# self.app.config['EMAIL_ENABLED'] = False -# self.app.config['WAIT_FOR_SERVICES'] = False -# Base(self.app) -# init_app(self.app) -# mail.init_app(self.app) -# self.submitter = domain.User(1234, email='j.user@somewhere.edu', -# forename='Jane', surname='User', -# endorsements=['cs.DL', 'cs.IR']) -# self.unicode_submitter = domain.User(12345, -# email='j.user@somewhere.edu', -# forename='大', surname='用户', -# endorsements=['cs.DL', 'cs.IR']) - -# @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) -# def test_classic_workflow(self, submitter=None, metadata=None, -# authors=None): -# """Submitter proceeds through workflow in a linear fashion.""" - -# # Instantiate objects that have not yet been instantiated or use defaults. -# if submitter is None: -# submitter = self.submitter -# if metadata is None: -# metadata = [ -# ('title', 'Foo title'), -# ('abstract', "One morning, as Gregor Samsa was waking up..."), -# ('comments', '5 pages, 2 turtle doves'), -# ('report_num', 'asdf1234'), -# ('doi', '10.01234/56789'), -# ('journal_ref', 'Foo Rev 1, 2 (1903)') -# ] -# metadata = dict(metadata) - - -# # TODO: Process data in dictionary form to Author objects. -# if authors is None: -# authors = [Author(order=0, -# forename='Bob', -# surname='Paulson', -# email='Robert.Paulson@nowhere.edu', -# affiliation='Fight Club' -# )] - -# with in_memory_db(self.app) as session: -# # Submitter clicks on 'Start new submission' in the user dashboard. -# submission, stack = save( -# CreateSubmission(creator=submitter) -# ) -# self.assertIsNotNone(submission.submission_id, -# "A submission ID is assigned") -# self.assertEqual(len(stack), 1, "A single command is executed.") - -# db_submission = session.query(classic.models.Submission)\ -# .get(submission.submission_id) -# self.assertEqual(db_submission.submission_id, -# submission.submission_id, -# "A row is added to the submission table") -# self.assertEqual(db_submission.submitter_id, -# submitter.native_id, -# "Submitter ID set on submission") -# self.assertEqual(db_submission.submitter_email, -# submitter.email, -# "Submitter email set on submission") -# self.assertEqual(db_submission.submitter_name, submitter.name, -# "Submitter name set on submission") -# self.assertEqual(db_submission.created.replace(tzinfo=UTC), -# submission.created, -# "Creation datetime set correctly") - -# # TODO: What else to check here? - -# # /start: Submitter completes the start submission page. -# license_uri = 'http://creativecommons.org/publicdomain/zero/1.0/' -# submission, stack = save( -# ConfirmContactInformation(creator=submitter), -# ConfirmAuthorship( -# creator=submitter, -# submitter_is_author=True -# ), -# SetLicense( -# creator=submitter, -# license_uri=license_uri, -# license_name='CC0 1.0' -# ), -# ConfirmPolicy(creator=submitter), -# SetPrimaryClassification( -# creator=submitter, -# category='cs.DL' -# ), -# submission_id=submission.submission_id -# ) - -# self.assertEqual(len(stack), 6, -# "Six commands have been executed in total.") - -# db_submission = session.query(classic.models.Submission)\ -# .get(submission.submission_id) -# self.assertEqual(db_submission.userinfo, 1, -# "Contact verification set correctly in database.") -# self.assertEqual(db_submission.is_author, 1, -# "Authorship status set correctly in database.") -# self.assertEqual(db_submission.license, license_uri, -# "License set correctly in database.") -# self.assertEqual(db_submission.agree_policy, 1, -# "Policy acceptance set correctly in database.") -# self.assertEqual(len(db_submission.categories), 1, -# "A single category is associated in the database") -# self.assertEqual(db_submission.categories[0].is_primary, 1, -# "Primary category is set correct in the database") -# self.assertEqual(db_submission.categories[0].category, 'cs.DL', -# "Primary category is set correct in the database") - -# # /addfiles: Submitter has uploaded files to the file management -# # service, and verified that they compile. Now they associate the -# # content package with the submission. -# submission, stack = save( -# SetUploadPackage( -# creator=submitter, -# checksum="a9s9k342900skks03330029k", -# source_format=domain.submission.SubmissionContent.Format('tex'), -# identifier=123, -# uncompressed_size=593992, -# compressed_size=593992 -# ), -# submission_id=submission.submission_id -# ) - -# self.assertEqual(len(stack), 7, -# "Seven commands have been executed in total.") -# db_submission = session.query(classic.models.Submission)\ -# .get(submission.submission_id) -# self.assertEqual(db_submission.must_process, 1, -# "There is no compilation yet") -# self.assertEqual(db_submission.source_size, 593992, -# "Source package size set correctly in database") -# self.assertEqual(db_submission.source_format, 'tex', -# "Source format set correctly in database") - -# # /metadata: Submitter adds metadata to their submission, including -# # authors. In this package, we model authors in more detail than -# # in the classic system, but we should preserve the canonical -# # format in the db for legacy components' sake. -# submission, stack = save( -# SetTitle(creator=self.submitter, title=metadata['title']), -# SetAbstract(creator=self.submitter, -# abstract=metadata['abstract']), -# SetComments(creator=self.submitter, -# comments=metadata['comments']), -# SetJournalReference(creator=self.submitter, -# journal_ref=metadata['journal_ref']), -# SetDOI(creator=self.submitter, doi=metadata['doi']), -# SetReportNumber(creator=self.submitter, -# report_num=metadata['report_num']), -# SetAuthors(creator=submitter, authors=authors), -# submission_id=submission.submission_id -# ) -# db_submission = session.query(classic.models.Submission) \ -# .get(submission.submission_id) -# self.assertEqual(db_submission.title, dict(metadata)['title'], -# "Title updated as expected in database") -# self.assertEqual(db_submission.abstract, -# dict(metadata)['abstract'], -# "Abstract updated as expected in database") -# self.assertEqual(db_submission.comments, -# dict(metadata)['comments'], -# "Comments updated as expected in database") -# self.assertEqual(db_submission.report_num, -# dict(metadata)['report_num'], -# "Report number updated as expected in database") -# self.assertEqual(db_submission.doi, dict(metadata)['doi'], -# "DOI updated as expected in database") -# self.assertEqual(db_submission.journal_ref, -# dict(metadata)['journal_ref'], -# "Journal ref updated as expected in database") - -# author_str = ';'.join( -# [f"{author.forename} {author.surname} ({author.affiliation})" -# for author in authors] -# ) -# self.assertEqual(db_submission.authors, -# author_str, -# "Authors updated in canonical format in database") -# self.assertEqual(len(stack), 14, -# "Fourteen commands have been executed in total.") - -# # /preview: Submitter adds a secondary classification. -# submission, stack = save( -# AddSecondaryClassification( -# creator=submitter, -# category='cs.IR' -# ), -# submission_id=submission.submission_id -# ) -# db_submission = session.query(classic.models.Submission)\ -# .get(submission.submission_id) - -# self.assertEqual(len(db_submission.categories), 2, -# "A secondary category is added in the database") -# secondaries = [ -# db_cat for db_cat in db_submission.categories -# if db_cat.is_primary == 0 -# ] -# self.assertEqual(len(secondaries), 1, -# "A secondary category is added in the database") -# self.assertEqual(secondaries[0].category, 'cs.IR', -# "A secondary category is added in the database") -# self.assertEqual(len(stack), 15, -# "Fifteen commands have been executed in total.") - -# # /preview: Submitter finalizes submission. -# finalize = FinalizeSubmission(creator=submitter) -# submission, stack = save( -# finalize, submission_id=submission.submission_id -# ) -# db_submission = session.query(classic.models.Submission)\ -# .get(submission.submission_id) - -# self.assertEqual(db_submission.status, db_submission.SUBMITTED, -# "Submission status set correctly in database") -# self.assertEqual(db_submission.submit_time.replace(tzinfo=UTC), -# finalize.created, -# "Submit time is set.") -# self.assertEqual(len(stack), 16, -# "Sixteen commands have been executed in total.") - -# def test_unicode_submitter(self): -# """Submitter proceeds through workflow in a linear fashion.""" -# submitter = self.unicode_submitter -# metadata = [ -# ('title', '优秀的称号'), -# ('abstract', "当我有一天正在上学的时候当我有一天正在上学的时候"), -# ('comments', '5页2龟鸠'), -# ('report_num', 'asdf1234'), -# ('doi', '10.01234/56789'), -# ('journal_ref', 'Foo Rev 1, 2 (1903)') -# ] -# authors = [Author(order=0, forename='惊人', surname='用户', -# email='amazing.user@nowhere.edu', -# affiliation='Fight Club')] -# with self.app.app_context(): -# self.app.config['ENABLE_CALLBACKS'] = 0 -# self.test_classic_workflow(submitter=submitter, metadata=metadata, -# authors=authors) - -# def test_texism_titles(self): -# """Submitter proceeds through workflow in a linear fashion.""" -# metadata = [ -# ('title', 'Revisiting $E = mc^2$'), -# ('abstract', "$E = mc^2$ is a foundational concept in physics"), -# ('comments', '5 pages, 2 turtle doves'), -# ('report_num', 'asdf1234'), -# ('doi', '10.01234/56789'), -# ('journal_ref', 'Foo Rev 1, 2 (1903)') -# ] -# with self.app.app_context(): -# self.app.config['ENABLE_CALLBACKS'] = 1 -# self.test_classic_workflow(metadata=metadata) - - -class TestReplacementIntegration(TestCase): - """Test integration with the classic database with replacements.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - cls.app.config['WAIT_FOR_SERVICES'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """An arXiv user is submitting a new paper.""" - self.submitter = domain.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL']) - - # Create and finalize a new submission. - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - with self.app.app_context(): - classic.create_all() - metadata=dict([ - ('title', 'Foo title'), - ('abstract', "One morning, as Gregor Samsa was..."), - ('comments', '5 pages, 2 turtle doves'), - ('report_num', 'asdf1234'), - ('doi', '10.01234/56789'), - ('journal_ref', 'Foo Rev 1, 2 (1903)') - ]) - self.submission, _ = save( - CreateSubmission(creator=self.submitter), - ConfirmContactInformation(creator=self.submitter), - ConfirmAuthorship( - creator=self.submitter, - submitter_is_author=True - ), - SetLicense( - creator=self.submitter, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.submitter), - SetPrimaryClassification( - creator=self.submitter, - category='cs.DL' - ), - SetUploadPackage( - creator=self.submitter, - checksum="a9s9k342900skks03330029k", - source_format=domain.submission.SubmissionContent.Format('tex'), - identifier=123, - uncompressed_size=593992, - compressed_size=593992 - ), - SetTitle(creator=self.submitter, title=metadata['title']), - SetAbstract(creator=self.submitter, - abstract=metadata['abstract']), - SetComments(creator=self.submitter, - comments=metadata['comments']), - SetJournalReference( - creator=self.submitter, - journal_ref=metadata['journal_ref'] - ), - SetDOI(creator=self.submitter, doi=metadata['doi']), - SetReportNumber(creator=self.submitter, - report_num=metadata['report_num']), - SetAuthors( - creator=self.submitter, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.submitter) - ) - - # Now publish. - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - primary = self.submission.primary_classification.category - db_submission.document = classic.models.Document( - document_id=1, - paper_id='1901.00123', - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=primary, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_submission.doc_paper_id = '1901.00123' - session.add(db_submission) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_replacement(self): - """User has started a replacement submission.""" - with self.app.app_context(): - submission_to_replace, _ = load(self.submission.submission_id) - creation_event = CreateSubmissionVersion(creator=self.submitter) - replacement, _ = save(creation_event, - submission_id=self.submission.submission_id) - - with self.app.app_context(): - replacement, _ = load(replacement.submission_id) - - session = classic.current_session() - db_replacement = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.doc_paper_id - == replacement.arxiv_id) \ - .order_by(classic.models.Submission.submission_id.desc()) \ - .first() - - # Verify that the round-trip on the replacement submission worked - # as expected. - self.assertEqual(replacement.arxiv_id, - submission_to_replace.arxiv_id) - self.assertEqual(replacement.version, - submission_to_replace.version + 1) - self.assertEqual(replacement.status, Submission.WORKING) - self.assertTrue(submission_to_replace.is_announced) - self.assertFalse(replacement.is_announced) - - self.assertIsNone(replacement.source_content) - - self.assertFalse(replacement.submitter_contact_verified) - self.assertFalse(replacement.submitter_accepts_policy) - self.assertFalse(replacement.submitter_confirmed_preview) - self.assertFalse(replacement.submitter_contact_verified) - - # Verify that the database is in the right state for downstream - # integrations. - self.assertEqual(db_replacement.status, - classic.models.Submission.NEW) - self.assertEqual(db_replacement.type, - classic.models.Submission.REPLACEMENT) - self.assertEqual(db_replacement.doc_paper_id, '1901.00123') - - -class TestJREFIntegration(TestCase): - """Test integration with the classic database with JREF submissions.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - cls.app.config['WAIT_FOR_SERVICES'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """An arXiv user is submitting a new paper.""" - self.submitter = domain.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL']) - - # Create and finalize a new submission. - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - with self.app.app_context(): - classic.create_all() - metadata=dict([ - ('title', 'Foo title'), - ('abstract', "One morning, as Gregor Samsa was..."), - ('comments', '5 pages, 2 turtle doves'), - ('report_num', 'asdf1234') - ]) - self.submission, _ = save( - CreateSubmission(creator=self.submitter), - ConfirmContactInformation(creator=self.submitter), - ConfirmAuthorship( - creator=self.submitter, - submitter_is_author=True - ), - SetLicense( - creator=self.submitter, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.submitter), - SetPrimaryClassification( - creator=self.submitter, - category='cs.DL' - ), - SetUploadPackage( - creator=self.submitter, - checksum="a9s9k342900skks03330029k", - source_format=domain.submission.SubmissionContent.Format('tex'), - identifier=123, - uncompressed_size=593992, - compressed_size=593992 - ), - SetTitle(creator=self.submitter, - title=metadata['title']), - SetAbstract(creator=self.submitter, - abstract=metadata['abstract']), - SetComments(creator=self.submitter, - comments=metadata['comments']), - SetReportNumber(creator=self.submitter, - report_num=metadata['report_num']), - SetAuthors( - creator=self.submitter, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - ConfirmSourceProcessed( - creator=self.submitter, - source_id=123, - source_checksum="a9s9k342900skks03330029k", - preview_checksum="foopreviewchex==", - size_bytes=1234, - added=datetime.now(UTC) - ), - ConfirmPreview(creator=self.submitter, - preview_checksum="foopreviewchex=="), - FinalizeSubmission(creator=self.submitter) - ) - - # Now publish. - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - primary = self.submission.primary_classification.category - db_submission.document = classic.models.Document( - document_id=1, - paper_id='1901.00123', - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=primary, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_submission.doc_paper_id = '1901.00123' - session.add(db_submission) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_jref(self): - """User has started a JREF submission.""" - with self.app.app_context(): - session = classic.current_session() - submission_to_jref, _ = load(self.submission.submission_id) - event = SetJournalReference( - creator=self.submitter, - journal_ref='Foo Rev 1, 2 (1903)' - ) - jref_submission, _ = save( - event, - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - jref_submission, _ = load(jref_submission.submission_id) - session = classic.current_session() - db_jref = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.doc_paper_id - == jref_submission.arxiv_id) \ - .filter(classic.models.Submission.type - == classic.models.Submission.JOURNAL_REFERENCE) \ - .order_by(classic.models.Submission.submission_id.desc()) \ - .first() - - # Verify that the round-trip on the replacement submission worked - # as expected. - self.assertEqual(jref_submission.arxiv_id, - submission_to_jref.arxiv_id) - self.assertEqual(jref_submission.version, - submission_to_jref.version, - "The paper version should not change") - self.assertEqual(jref_submission.status, Submission.ANNOUNCED) - self.assertTrue(submission_to_jref.is_announced) - self.assertTrue(jref_submission.is_announced) - - self.assertIsNotNone(jref_submission.source_content) - - self.assertTrue(jref_submission.submitter_contact_verified) - self.assertTrue(jref_submission.submitter_accepts_policy) - self.assertTrue(jref_submission.submitter_confirmed_preview) - self.assertTrue(jref_submission.submitter_contact_verified) - - # Verify that the database is in the right state for downstream - # integrations. - self.assertEqual(db_jref.status, - classic.models.Submission.PROCESSING_SUBMISSION) - self.assertEqual(db_jref.type, - classic.models.Submission.JOURNAL_REFERENCE) - self.assertEqual(db_jref.doc_paper_id, '1901.00123') - self.assertEqual(db_jref.submitter_id, - jref_submission.creator.native_id) - - -class TestWithdrawalIntegration(TestCase): - """ - Test integration with the classic database concerning withdrawals. - - The :class:`.domain.submission.Submission` representation has only two - statuses: :attr:`.domain.submission.WITHDRAWAL_REQUESTED` and - :attr:`.domain.submission.WITHDRAWN`. Like other post-publish operations, - we are simply adding events to the single stream for the original - submission ID. This screens off details that are due to the underlying - implementation, and focuses on how humans are actually interacting with - withdrawals. - - On the classic side, we create a new row in the submission table for a - withdrawal request, and it passes through the same states as a regular - submission. - """ - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - cls.app.config['WAIT_FOR_SERVICES'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """An arXiv user is submitting a new paper.""" - self.submitter = domain.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL']) - - # Create and finalize a new submission. - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - with self.app.app_context(): - classic.create_all() - metadata=dict([ - ('title', 'Foo title'), - ('abstract', "One morning, as Gregor Samsa was..."), - ('comments', '5 pages, 2 turtle doves'), - ('report_num', 'asdf1234'), - ('doi', '10.01234/56789'), - ('journal_ref', 'Foo Rev 1, 2 (1903)') - ]) - self.submission, _ = save( - CreateSubmission(creator=self.submitter), - ConfirmContactInformation(creator=self.submitter), - ConfirmAuthorship( - creator=self.submitter, - submitter_is_author=True - ), - SetLicense( - creator=self.submitter, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.submitter), - SetPrimaryClassification( - creator=self.submitter, - category='cs.DL' - ), - SetUploadPackage( - creator=self.submitter, - checksum="a9s9k342900skks03330029k", - source_format=domain.submission.SubmissionContent.Format('tex'), - identifier=123, - uncompressed_size=593992, - compressed_size=593992 - ), - SetTitle(creator=self.submitter, title=metadata['title']), - SetAbstract(creator=self.submitter, - abstract=metadata['abstract']), - SetComments(creator=self.submitter, - comments=metadata['comments']), - SetJournalReference( - creator=self.submitter, - journal_ref=metadata['journal_ref'] - ), - SetDOI(creator=self.submitter, doi=metadata['doi']), - SetReportNumber(creator=self.submitter, - report_num=metadata['report_num']), - SetAuthors( - creator=self.submitter, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.submitter) - ) - self.submission_id = self.submission.submission_id - - # Announce. - with self.app.app_context(): - session = classic.current_session() - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - primary = self.submission.primary_classification.category - db_submission.document = classic.models.Document( - document_id=1, - paper_id='1901.00123', - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=primary, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_submission.doc_paper_id = '1901.00123' - session.add(db_submission) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_request_withdrawal(self): - """Request a withdrawal.""" - with self.app.app_context(): - session = classic.current_session() - event = RequestWithdrawal(creator=self.submitter, - reason="short people got no reason") - submission, _ = save(event, submission_id=self.submission_id) - - submission, _ = load(self.submission_id) - self.assertEqual(submission.status, domain.Submission.ANNOUNCED) - request = list(submission.user_requests.values())[0] - self.assertEqual(request.reason_for_withdrawal, event.reason) - - wdr = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.doc_paper_id == submission.arxiv_id) \ - .order_by(classic.models.Submission.submission_id.desc()) \ - .first() - self.assertEqual(wdr.status, - classic.models.Submission.PROCESSING_SUBMISSION) - self.assertEqual(wdr.type, classic.models.Submission.WITHDRAWAL) - self.assertIn(f"Withdrawn: {event.reason}", wdr.comments) - - -class TestPublicationIntegration(TestCase): - """ - Test integration with the classic database concerning publication. - - Since the publication process continues to run outside of the event model - in the short term, we need to be certain that publication-related changes - are represented accurately in this project. - """ - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - cls.app.config['WAIT_FOR_SERVICES'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """An arXiv user is submitting a new paper.""" - self.submitter = domain.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL']) - - # Create and finalize a new submission. - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - with self.app.app_context(): - classic.create_all() - metadata=dict([ - ('title', 'Foo title'), - ('abstract', "One morning, as Gregor Samsa was..."), - ('comments', '5 pages, 2 turtle doves'), - ('report_num', 'asdf1234'), - ('doi', '10.01234/56789'), - ('journal_ref', 'Foo Rev 1, 2 (1903)') - ]) - self.submission, _ = save( - CreateSubmission(creator=self.submitter), - ConfirmContactInformation(creator=self.submitter), - ConfirmAuthorship( - creator=self.submitter, - submitter_is_author=True - ), - SetLicense( - creator=self.submitter, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.submitter), - SetPrimaryClassification( - creator=self.submitter, - category='cs.DL' - ), - SetUploadPackage( - creator=self.submitter, - checksum="a9s9k342900skks03330029k", - source_format=domain.submission.SubmissionContent.Format('tex'), - identifier=123, - uncompressed_size=593992, - compressed_size=593992 - ), - SetTitle(creator=self.submitter, - title=metadata['title']), - SetAbstract(creator=self.submitter, - abstract=metadata['abstract']), - SetComments(creator=self.submitter, - comments=metadata['comments']), - SetJournalReference( - creator=self.submitter, - journal_ref=metadata['journal_ref'] - ), - SetDOI(creator=self.submitter, doi=metadata['doi']), - SetReportNumber(creator=self.submitter, - report_num=metadata['report_num']), - SetAuthors( - creator=self.submitter, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.submitter) - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_publication_status_is_reflected(self): - """The submission has been announced/announced.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - primary = self.submission.primary_classification.category - db_submission.document = classic.models.Document( - document_id=1, - paper_id='1901.00123', - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=primary, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - session.add(db_submission) - session.commit() - - # Submission state should reflect publication status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.ANNOUNCED, - "Submission should have announced status.") - self.assertEqual(submission.arxiv_id, "1901.00123", - "arXiv paper ID should be set") - self.assertFalse(submission.is_active, - "Announced submission should no longer be active") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_publication_status_is_reflected_after_files_expire(self): - """The submission has been announced/announced, and files expired.""" - paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.DELETED_ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - primary = self.submission.primary_classification.category - db_submission.document = classic.models.Document( - document_id=1, - paper_id=paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=primary, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_submission.doc_paper_id = paper_id - session.add(db_submission) - session.commit() - - # Submission state should reflect publication status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.ANNOUNCED, - "Submission should have announced status.") - self.assertEqual(submission.arxiv_id, "1901.00123", - "arXiv paper ID should be set") - self.assertFalse(submission.is_active, - "Announced submission should no longer be active") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_scheduled_status_is_reflected(self): - """The submission has been scheduled for publication today.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.PROCESSING - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.SCHEDULED, - "Submission should have scheduled status.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_scheduled_status_is_reflected_processing_submission(self): - """The submission has been scheduled for publication today.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.PROCESSING_SUBMISSION - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.SCHEDULED, - "Submission should have scheduled status.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_scheduled_status_is_reflected_prior_to_announcement(self): - """The submission is being announced; not yet announced.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.NEEDS_EMAIL - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.SCHEDULED, - "Submission should have scheduled status.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_scheduled_tomorrow_status_is_reflected(self): - """The submission has been scheduled for publication tomorrow.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.NEXT_PUBLISH_DAY - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.SCHEDULED, - "Submission should be scheduled for tomorrow.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_publication_failed(self): - """The submission was not announced successfully.""" - with self.app.app_context(): - session = classic.current_session() - - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = db_submission.ERROR_STATE - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.ERROR, - "Submission should have error status.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_deleted(self): - """The submission was deleted by the classic system.""" - with self.app.app_context(): - session = classic.current_session() - - for classic_status in classic.models.Submission.DELETED: - # Publication agent publishes the paper. - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - db_submission.status = classic_status - session.add(db_submission) - session.commit() - - # Submission state should reflect scheduled status. - submission, _ = load(self.submission.submission_id) - self.assertEqual(submission.status, submission.DELETED, - "Submission should have deleted status.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_deleted_in_ng(self): - """The submission was deleted in this package.""" - with self.app.app_context(): - session = classic.current_session() - self.submission, _ = save( - Rollback(creator=self.submitter), - submission_id=self.submission.submission_id - ) - - db_submission = session.query(classic.models.Submission)\ - .get(self.submission.submission_id) - self.assertEqual(db_submission.status, - classic.models.Submission.USER_DELETED) diff --git a/src/arxiv/submission/tests/examples/__init__.py b/src/arxiv/submission/tests/examples/__init__.py deleted file mode 100644 index 97fd7ed..0000000 --- a/src/arxiv/submission/tests/examples/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Tests based on user workflow examples. - -The tests in this version of the project assume that we are working in the NG -paradigm for submissions only, and that moderation, publication, etc continue -to rely on the classic model. -""" diff --git a/src/arxiv/submission/tests/examples/test_01_working_submission.py b/src/arxiv/submission/tests/examples/test_01_working_submission.py deleted file mode 100644 index 3572f28..0000000 --- a/src/arxiv/submission/tests/examples/test_01_working_submission.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Example 1: working submission.""" - -from unittest import TestCase, mock -import tempfile - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions -from ... import core - - -class TestWorkingSubmission(TestCase): - """ - Submitter creates a new submission, has completed some but not all fields. - - This is a typical scenario in which the user has missed a step, or left - something required blank. These should get caught early if we designed - the UI or API right, but it's possible that something slipped through. - """ - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create and partially complete the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title='the best title', **self.defaults) - ) - self.submission_id = self.submission.submission_id - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_working_state(self): - """The submission in in the working state.""" - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.NOT_SUBMITTED, - "The classic submission is in the not submitted" - " state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_delete(self): - """The submission can be deleted.""" - with self.app.app_context(): - save(domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - - self.assertEqual(submission.status, - domain.event.Submission.DELETED, - "Submission is in the deleted state") - self.assertFalse(submission.is_active, - "The submission is no longer considered active.") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission_id) - self.assertEqual(submission.status, - domain.event.Submission.DELETED, - "Submission is in the deleted state") - self.assertFalse(submission.is_active, - "The submission is no longer considered active.") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.USER_DELETED, - "The classic submission is in the DELETED state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_finalize_submission(self): - """The submission cannot be finalized.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a FinalizeSubmission command results in an" - " exception.")): - save(domain.event.FinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_replace_submission(self): - """The submission cannot be replaced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a CreateSubmissionVersion command results in an" - " exception.")): - save(domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_withdraw_submission(self): - """The submission cannot be withdrawn.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a RequestWithdrawal command results in an" - " exception.")): - save(domain.event.RequestWithdrawal(reason="the best reason", - **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_be_unfinalized(self): - """The submission cannot be unfinalized.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating an UnFinalizeSubmission command results in an" - " exception.")): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_working_state() diff --git a/src/arxiv/submission/tests/examples/test_02_finalized_submission.py b/src/arxiv/submission/tests/examples/test_02_finalized_submission.py deleted file mode 100644 index 043c3e4..0000000 --- a/src/arxiv/submission/tests/examples/test_02_finalized_submission.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Example 2: finalized submission.""" - -from unittest import TestCase, mock -import tempfile - -from flask import Flask - -from ...services import classic, StreamPublisher - -from ... import save, load, load_fast, domain, exceptions -from ... import core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' - - -class TestFinalizedSubmission(TestCase): - """ - Submitter creates, completes, and finalizes a new submission. - - At this point the submission is in the queue for moderation and - announcement. - """ - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, and complete the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category="cs.DL", - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_submitted_state(self): - """ - The submission is now submitted. - - This moves the submission into consideration for announcement, and - is visible to moderators. - """ - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.SUBMITTED, - "The submission is in the submitted state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.SUBMITTED, - "The submission is in the submitted state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.SUBMITTED, - "The classic submission is in the SUBMITTED" - " state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_replace_submission(self): - """The submission cannot be replaced: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a CreateSubmissionVersion command results in an" - " exception.")): - save(domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_withdraw_submission(self): - """The submission cannot be withdrawn: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a RequestWithdrawal command results in an" - " exception.")): - save(domain.event.RequestWithdrawal(reason="the best reason", - **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_edit_submission(self): - """The submission cannot be changed: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetTitle command results in an exception.")): - save(domain.event.SetTitle(title="A better title", - **self.defaults), - submission_id=self.submission.submission_id) - - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetDOI command results in an exception.")): - save(domain.event.SetDOI(doi="10.1000/182", **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_be_unfinalized(self): - """The submission can be unfinalized.""" - with self.app.app_context(): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.NOT_SUBMITTED, - "The classic submission is in the not submitted" - " state") diff --git a/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py b/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py deleted file mode 100644 index 009f9f8..0000000 --- a/src/arxiv/submission/tests/examples/test_03_on_hold_submission.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Example 3: submission on hold.""" - -from unittest import TestCase, mock -import tempfile - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' - - -class TestOnHoldSubmission(TestCase): - """ - Submitter finalizes a new submission; the system places it on hold. - - This can be due to a variety of reasons. - """ - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, and complete the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category="cs.DL", - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Place the submission on hold. - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ON_HOLD - session.add(db_row) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_submitted_state(self): - """The submission is now on hold.""" - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - # self.assertEqual(submission.status, - # domain.submission.Submission.ON_HOLD, - # "The submission is in the hold state") - self.assertTrue(submission.is_on_hold, "The submission is on hold") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - # self.assertEqual(submission.status, - # domain.submission.Submission.ON_HOLD, - # "The submission is in the hold state") - self.assertTrue(submission.is_on_hold, "The submission is on hold") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.ON_HOLD, - "The classic submission is in the ON_HOLD" - " state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_replace_submission(self): - """The submission cannot be replaced: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a CreateSubmissionVersion command results in an" - " exception.")): - save(domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_withdraw_submission(self): - """The submission cannot be withdrawn: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a RequestWithdrawal command results in an" - " exception.")): - save(domain.event.RequestWithdrawal(reason="the best reason", - **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_edit_submission(self): - """The submission cannot be changed: it hasn't yet been announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetTitle command results in an exception.")): - save(domain.event.SetTitle(title="A better title", - **self.defaults), - submission_id=self.submission.submission_id) - - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetDOI command results in an exception.")): - save(domain.event.SetDOI(doi="10.1000/182", **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_submitted_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_be_unfinalized(self): - """The submission can be unfinalized.""" - with self.app.app_context(): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(len(submission.versions), 0, - "There are no announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.NOT_SUBMITTED, - "The classic submission is in the not submitted" - " state") - self.assertEqual(row.sticky_status, - classic.models.Submission.ON_HOLD, - "The hold is preserved as a sticky status") diff --git a/src/arxiv/submission/tests/examples/test_04_published_submission.py b/src/arxiv/submission/tests/examples/test_04_published_submission.py deleted file mode 100644 index 6891a63..0000000 --- a/src/arxiv/submission/tests/examples/test_04_published_submission.py +++ /dev/null @@ -1,531 +0,0 @@ -"""Example 4: submission is announced.""" - -from unittest import TestCase, mock -import tempfile -from datetime import datetime -from pytz import UTC - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' - - -class TestAnnouncedSubmission(TestCase): - """Submitter finalizes a new submission, and it is eventually announced.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_announced_state(self): - """The submission is now announced.""" - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state") - self.assertTrue(submission.is_announced, "Submission is announced") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state") - self.assertTrue(submission.is_announced, "Submission is announced") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission).all() - - self.assertEqual(len(db_rows), 1, - "There is one row in the submission table") - row = db_rows[0] - self.assertEqual(row.type, - classic.models.Submission.NEW_SUBMISSION, - "The classic submission has type 'new'") - self.assertEqual(row.status, - classic.models.Submission.ANNOUNCED, - "The classic submission is in the ANNOUNCED" - " state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_replace_submission(self): - """The submission can be replaced, resulting in a new version.""" - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.NOT_SUBMITTED, - "The second row is in not submitted state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_withdraw_submission(self): - """The submitter can request withdrawal of the submission.""" - withdrawal_reason = "the best reason" - with self.app.app_context(): - submission, events = save( - domain.event.RequestWithdrawal(reason=withdrawal_reason, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.WITHDRAWAL, - "The second row has type 'withdrawal'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The second row is in the processing submission" - " state.") - - # Cannot submit another withdrawal request while one is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestWithdrawal(reason="more reason", - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_request_crosslist(self): - """The submitter can request cross-list classification.""" - category = "cs.IR" - with self.app.app_context(): - submission, events = save( - domain.event.RequestCrossList(categories=[category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The second row is in the processing submission" - " state.") - - # Cannot submit another cross-list request while one is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestCrossList(categories=["q-fin.CP"], - **self.defaults), - submission_id=self.submission.submission_id) - - # Cannot submit a withdrawal request while a cross-list is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestWithdrawal(reason="more reason", - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_edit_submission_metadata(self): - """The submission metadata cannot be changed without a new version.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetTitle command results in an exception.")): - save(domain.event.SetTitle(title="A better title", - **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_announced_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_changing_doi(self): - """Submitter can set the DOI.""" - new_doi = "10.1000/182" - new_journal_ref = "Baz 1993" - new_report_num = "Report 82" - with self.app.app_context(): - submission, events = save( - domain.event.SetDOI(doi=new_doi, **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetJournalReference(journal_ref=new_journal_ref, - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetReportNumber(report_num=new_report_num, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.JOURNAL_REFERENCE, - "The second row has type journal ref") - self.assertEqual(db_rows[1].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The second row is in the processing submission" - " state.") - self.assertEqual(db_rows[1].doi, new_doi, - "The DOI is updated in the database.") - self.assertEqual(db_rows[1].journal_ref, new_journal_ref, - "The journal ref is updated in the database.") - self.assertEqual(db_rows[1].report_num, new_report_num, - "The report number is updated in the database.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_be_unfinalized(self): - """The submission cannot be unfinalized, because it is announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_announced_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_rolling_back_does_not_clobber_jref_changes(self): - """If user submits a JREF, rolling back does not clobber changes.""" - # These changes result in what we consider a "JREF submission" in - # classic. But we're moving away from that way of thinking in NG, so - # it should be somewhat opaque in a replacement/deletion scenario. - new_doi = "10.1000/182" - new_journal_ref = "Baz 1993" - new_report_num = "Report 82" - with self.app.app_context(): - submission, events = save( - domain.event.SetDOI(doi=new_doi, **self.defaults), - domain.event.SetJournalReference(journal_ref=new_journal_ref, - **self.defaults), - domain.event.SetReportNumber(report_num=new_report_num, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Now we get a replacement. - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - domain.event.SetTitle(title='A new and better title', - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Now the user rolls back the replacement. - with self.app.app_context(): - submission, events = save( - domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. The JREF changes shoulds stick. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is still updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is still updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is stil updated.") - self.assertEqual(submission.metadata.title, - self.submission.metadata.title, - "The title is reverted to the last announced" - " version.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is still updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is still updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is stil updated.") - self.assertEqual(submission.metadata.title, - self.submission.metadata.title, - "The title is reverted to the last announced" - " version.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") diff --git a/src/arxiv/submission/tests/examples/test_05_working_replacement.py b/src/arxiv/submission/tests/examples/test_05_working_replacement.py deleted file mode 100644 index 9f33d31..0000000 --- a/src/arxiv/submission/tests/examples/test_05_working_replacement.py +++ /dev/null @@ -1,465 +0,0 @@ -"""Example 5: submission is being replaced.""" - -from unittest import TestCase, mock -import tempfile -from datetime import datetime -from pytz import UTC - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' - - -class TestReplacementSubmissionInProgress(TestCase): - """Submitter creates a replacement, and is working on updates.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - with self.app.app_context(): - self.submission, self.events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_working_state(self): - """The submission is now in working state.""" - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertIsInstance(self.events[-2], domain.event.Announce, - "An Announce event is inserted.") - self.assertIsInstance(self.events[-1], - domain.event.CreateSubmissionVersion, - "A CreateSubmissionVersion event is" - " inserted.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertIsInstance(self.events[-2], domain.event.Announce, - "An Announce event is inserted.") - self.assertIsInstance(self.events[-1], - domain.event.CreateSubmissionVersion, - "A CreateSubmissionVersion event is" - " inserted.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.NOT_SUBMITTED, - "The second row is in not submitted state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_replace_submission_again(self): - """The submission cannot be replaced again while in working state.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - self.submission, self.events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_withdraw_submission(self): - """The submitter cannot request withdrawal of the submission.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - submission, events = save( - domain.event.RequestWithdrawal(reason="the best reason", - **self.defaults), - submission_id=self.submission.submission_id - ) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_edit_submission_metadata(self): - """The submission metadata can now be changed.""" - new_title = "A better title" - with self.app.app_context(): - submission, events = save( - domain.event.SetTitle(title=new_title, **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.metadata.title, new_title, - "The submission is changed") - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertIsInstance(events[-3], domain.event.Announce, - "An Announce event is inserted.") - self.assertIsInstance(events[-2], - domain.event.CreateSubmissionVersion, - "A CreateSubmissionVersion event is" - " inserted.") - self.assertIsInstance(events[-1], - domain.event.SetTitle, - "Metadata update events are reflected") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.metadata.title, new_title, - "The submission is changed") - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertIsInstance(events[-3], domain.event.Announce, - "An Announce event is inserted.") - self.assertIsInstance(events[-2], - domain.event.CreateSubmissionVersion, - "A CreateSubmissionVersion event is" - " inserted.") - self.assertIsInstance(events[-1], - domain.event.SetTitle, - "Metadata update events are reflected") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[0].title, self.submission.metadata.title, - "Announced row is unchanged.") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.NOT_SUBMITTED, - "The second row is in not submitted state") - self.assertEqual(db_rows[1].title, new_title, - "Replacement row reflects the change.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_changing_doi(self): - """Submitter can set the DOI as part of the new version.""" - new_doi = "10.1000/182" - new_journal_ref = "Baz 1993" - new_report_num = "Report 82" - with self.app.app_context(): - submission, events = save( - domain.event.SetDOI(doi=new_doi, **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetJournalReference(journal_ref=new_journal_ref, - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetReportNumber(report_num=new_report_num, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state.") - - self.assertIsInstance(events[-5], domain.event.Announce, - "An Announce event is inserted.") - self.assertIsInstance(events[-4], - domain.event.CreateSubmissionVersion, - "A CreateSubmissionVersion event is" - " inserted.") - self.assertIsInstance(events[-3], - domain.event.SetDOI, - "Metadata update events are reflected") - self.assertIsInstance(events[-2], - domain.event.SetJournalReference, - "Metadata update events are reflected") - self.assertIsInstance(events[-1], - domain.event.SetReportNumber, - "Metadata update events are reflected") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type replacement") - self.assertEqual(db_rows[1].status, - classic.models.Submission.NOT_SUBMITTED, - "The second row is in the not submitted state.") - self.assertEqual(db_rows[1].doi, new_doi, - "The DOI is updated in the database.") - self.assertEqual(db_rows[1].journal_ref, new_journal_ref, - "The journal ref is updated in the database.") - self.assertEqual(db_rows[1].report_num, new_report_num, - "The report number is updated in the database.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_be_unfinalized(self): - """The submission cannot be unfinalized, as it is not finalized.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_working_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_revert_to_most_recent_announced_version(self): - """Submitter can abandon changes to their replacement.""" - new_doi = "10.1000/182" - new_journal_ref = "Baz 1993" - new_report_num = "Report 82" - with self.app.app_context(): - submission, events = save( - domain.event.SetDOI(doi=new_doi, **self.defaults), - domain.event.SetJournalReference(journal_ref=new_journal_ref, - **self.defaults), - domain.event.SetReportNumber(report_num=new_report_num, - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.version, 1, - "Version number is rolled back") - self.assertEqual(submission.metadata.doi, - self.submission.metadata.doi, - "The DOI is reverted.") - self.assertEqual(submission.metadata.journal_ref, - self.submission.metadata.journal_ref, - "The journal ref is reverted.") - self.assertEqual(submission.metadata.report_num, - self.submission.metadata.report_num, - "The report number is reverted.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.version, 1, - "Version number is rolled back") - self.assertEqual(submission.metadata.doi, - self.submission.metadata.doi, - "The DOI is reverted.") - self.assertEqual(submission.metadata.journal_ref, - self.submission.metadata.journal_ref, - "The journal ref is reverted.") - self.assertEqual(submission.metadata.report_num, - self.submission.metadata.report_num, - "The report number is reverted.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type replacement") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is in the user deleted state.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_start_a_new_replacement_after_reverting(self): - """Submitter can start a new replacement after reverting.""" - with self.app.app_context(): - submission, events = save( - domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.version, 2, - "Version number is incremented.") - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "Submission is in working state") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") diff --git a/src/arxiv/submission/tests/examples/test_06_second_version_published.py b/src/arxiv/submission/tests/examples/test_06_second_version_published.py deleted file mode 100644 index 62555f9..0000000 --- a/src/arxiv/submission/tests/examples/test_06_second_version_published.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Example 6: second version of a submission is announced.""" - -from unittest import TestCase, mock -import tempfile -from datetime import datetime -from pytz import UTC - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' - - -class TestSecondVersionIsAnnounced(TestCase): - """Submitter creates a replacement, and it is announced.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create and publish two versions.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.drop_all() - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - with self.app.app_context(): - new_title = "A better title" - self.submission, self.events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=new_title, **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=domain.submission.SubmissionContent.Format('tex'), identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Announce second version. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - db_rows[1].status = classic.models.Submission.ANNOUNCED - session.add(db_rows[1]) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_is_in_announced_state(self): - """The submission is now in announced state.""" - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the publushed state") - self.assertIsInstance(events[-1], domain.event.Announce, - "An Announce event is inserted.") - p_evts = [e for e in events if isinstance(e, domain.event.Announce)] - self.assertEqual(len(p_evts), 2, "There are two publish events.") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the publushed state") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in announced state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_replace_submission(self): - """The submission can be replaced, resulting in a new version.""" - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 3, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 3, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.REPLACEMENT, - "The third row has type 'replacement'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.NOT_SUBMITTED, - "The third row is in not submitted state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_withdraw_submission(self): - """The submitter can request withdrawal of the submission.""" - withdrawal_reason = "the best reason" - with self.app.app_context(): - submission, events = save( - domain.event.RequestWithdrawal(reason=withdrawal_reason, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.WITHDRAWAL, - "The third row has type 'withdrawal'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_edit_submission_metadata(self): - """The submission metadata cannot be changed without a new version.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent, msg=( - "Creating a SetTitle command results in an exception.")): - save(domain.event.SetTitle(title="A better title", - **self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_announced_state() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_changing_doi(self): - """Submitter can set the DOI.""" - new_doi = "10.1000/182" - new_journal_ref = "Baz 1993" - new_report_num = "Report 82" - with self.app.app_context(): - submission, events = save( - domain.event.SetDOI(doi=new_doi, **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetJournalReference(journal_ref=new_journal_ref, - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = save( - domain.event.SetReportNumber(report_num=new_report_num, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.metadata.doi, new_doi, - "The DOI is updated.") - self.assertEqual(submission.metadata.journal_ref, new_journal_ref, - "The journal ref is updated.") - self.assertEqual(submission.metadata.report_num, new_report_num, - "The report number is updated.") - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is in the submitted state.") - self.assertEqual(len(submission.versions), 2, - "There are two announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.JOURNAL_REFERENCE, - "The third row has type journal ref") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") - self.assertEqual(db_rows[2].doi, new_doi, - "The DOI is updated in the database.") - self.assertEqual(db_rows[2].journal_ref, new_journal_ref, - "The journal ref is updated in the database.") - self.assertEqual(db_rows[2].report_num, new_report_num, - "The report number is updated in the database.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_be_unfinalized(self): - """The submission cannot be unfinalized, because it is announced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.UnFinalizeSubmission(**self.defaults), - submission_id=self.submission.submission_id) - - self.test_is_in_announced_state() diff --git a/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py b/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py deleted file mode 100644 index 5d416a6..0000000 --- a/src/arxiv/submission/tests/examples/test_07_cross_list_requested.py +++ /dev/null @@ -1,1086 +0,0 @@ -"""Example 7: cross-list request.""" - -from unittest import TestCase, mock -import tempfile -from datetime import datetime -from pytz import UTC - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' -TEX = domain.submission.SubmissionContent.Format('tex') - - -class TestCrossListRequested(TestCase): - """Submitter has requested that a cross-list classification be added.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - # Request cross-list classification - self.category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestCrossList(categories=[self.category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_has_pending_requests(self): - """The submission has an outstanding publication.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The second row is in the processing submission" - " state.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_replace_submission(self): - """The submission cannot be replaced.""" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_withdraw_submission(self): - """The submitter cannot request withdrawal.""" - withdrawal_reason = "the best reason" - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestWithdrawal(reason=withdrawal_reason, - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_cannot_request_another_crosslist(self): - """The submitter cannot request a second cross-list.""" - # Cannot submit another cross-list request while one is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestCrossList(categories=["q-fin.CP"], - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_request_is_rejected(self): - """If the request is 'removed' in classic, NG request is rejected.""" - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - db_rows[1].status = classic.models.Submission.REMOVED - session.add(db_rows[1]) - session.commit() - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There are no pending user request.") - self.assertEqual(len(submission.rejected_user_requests), 1, - "There is one rejected user request.") - self.assertIsInstance( - submission.rejected_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.rejected_user_requests[0].categories, - "Requested category is set on request.") - self.assertNotIn(self.category, submission.secondary_categories, - "Requested category is not added to submission") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There are no pending user request.") - self.assertEqual(len(submission.rejected_user_requests), 1, - "There is one rejected user request.") - self.assertIsInstance( - submission.rejected_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.rejected_user_requests[0].categories, - "Requested category is set on request.") - self.assertNotIn(self.category, submission.secondary_categories, - "Requested category is not added to submission") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_request_is_applied(self): - """If the request is announced in classic, NG request is 'applied'.""" - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - db_rows[1].status = classic.models.Submission.ANNOUNCED - session.add(db_rows[1]) - session.commit() - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There are no pending user request.") - self.assertEqual(len(submission.applied_user_requests), 1, - "There is one applied user request.") - self.assertIsInstance( - submission.applied_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.applied_user_requests[0].categories, - "Requested category is set on request.") - self.assertIn(self.category, submission.secondary_categories, - "Requested category is added to submission") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There are no pending user request.") - self.assertEqual(len(submission.applied_user_requests), 1, - "There is one applied user request.") - self.assertIsInstance( - submission.applied_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.applied_user_requests[0].categories, - "Requested category is set on request.") - self.assertIn(self.category, submission.secondary_categories, - "Requested category is added to submission") - - -class TestCrossListApplied(TestCase): - """Request for cross-list has been approved and applied.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - # Request cross-list classification - self.category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestCrossList(categories=[self.category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Apply. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - db_rows[1].status = classic.models.Submission.ANNOUNCED - session.add(db_rows[1]) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_has_applied_requests(self): - """The submission has an applied request.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.applied_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.applied_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.applied_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.applied_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.applied_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.applied_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in the processing submission" - " state.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_replace_submission(self): - """The submission can be replaced, resulting in a new version.""" - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in the announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.REPLACEMENT, - "The third row has type 'replacement'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.NOT_SUBMITTED, - "The third row is in not submitted state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_withdraw_submission(self): - """The submitter can request withdrawal of the submission.""" - withdrawal_reason = "the best reason" - with self.app.app_context(): - submission, events = save( - domain.event.RequestWithdrawal(reason=withdrawal_reason, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in the announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.WITHDRAWAL, - "The third row has type 'withdrawal'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") - - # Cannot submit another withdrawal request while one is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestWithdrawal(reason="more reason", - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_request_crosslist(self): - """The submitter can request cross-list classification.""" - category = "cs.LO" - with self.app.app_context(): - submission, events = save( - domain.event.RequestCrossList(categories=[category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.ANNOUNCED, - "The second row is in the announced state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.CROSS_LIST, - "The third row has type 'cross'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") - - -class TestCrossListRejected(TestCase): - """Request for cross-list has been rejected.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - # Request cross-list classification - self.category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestCrossList(categories=[self.category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Apply. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - db_rows[1].status = classic.models.Submission.REMOVED - session.add(db_rows[1]) - session.commit() - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_has_rejected_request(self): - """The submission has a rejected request.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There is are no pending user requests.") - self.assertEqual(len(submission.rejected_user_requests), 1, - "There is one rejected user request.") - self.assertIsInstance( - submission.rejected_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.rejected_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertFalse(submission.has_active_requests, - "The submission has no active requests.") - self.assertEqual(len(submission.pending_user_requests), 0, - "There is are no pending user requests.") - self.assertEqual(len(submission.rejected_user_requests), 1, - "There is one rejected user request.") - self.assertIsInstance( - submission.rejected_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(self.category, - submission.rejected_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.REMOVED, - "The second row is in the removed state.") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_replace_submission(self): - """The submission can be replaced, resulting in a new version.""" - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is in the working state") - self.assertEqual(submission.version, 2, - "The version number is incremented by 1") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.REMOVED, - "The second row is in the removed state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.REPLACEMENT, - "The third row has type 'replacement'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.NOT_SUBMITTED, - "The third row is in not submitted state") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_withdraw_submission(self): - """The submitter can request withdrawal of the submission.""" - withdrawal_reason = "the best reason" - with self.app.app_context(): - submission, events = save( - domain.event.RequestWithdrawal(reason=withdrawal_reason, - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance(submission.pending_user_requests[0], - domain.submission.WithdrawalRequest) - self.assertEqual( - submission.pending_user_requests[0].reason_for_withdrawal, - withdrawal_reason, - "Withdrawal reason is set on request." - ) - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.REMOVED, - "The second row is in the removed state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.WITHDRAWAL, - "The third row has type 'withdrawal'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") - - # Cannot submit another withdrawal request while one is pending. - with self.app.app_context(): - with self.assertRaises(exceptions.InvalidEvent): - save(domain.event.RequestWithdrawal(reason="more reason", - **self.defaults), - submission_id=self.submission.submission_id) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_request_crosslist(self): - """The submitter can request cross-list classification.""" - category = "cs.LO" - with self.app.app_context(): - submission, events = save( - domain.event.RequestCrossList(categories=[category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - # Check the submission state. - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is announced.") - self.assertTrue(submission.has_active_requests, - "The submission has an active request.") - self.assertEqual(len(submission.pending_user_requests), 1, - "There is one pending user request.") - self.assertIsInstance( - submission.pending_user_requests[0], - domain.submission.CrossListClassificationRequest - ) - self.assertIn(category, - submission.pending_user_requests[0].categories, - "Requested category is set on request.") - self.assertEqual(len(submission.versions), 1, - "There is one announced versions") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is announced") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.REMOVED, - "The second row is in the removed state") - self.assertEqual(db_rows[2].type, - classic.models.Submission.CROSS_LIST, - "The third row has type 'cross'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is in the processing submission" - " state.") diff --git a/src/arxiv/submission/tests/examples/test_10_abandon_submission.py b/src/arxiv/submission/tests/examples/test_10_abandon_submission.py deleted file mode 100644 index ce905d4..0000000 --- a/src/arxiv/submission/tests/examples/test_10_abandon_submission.py +++ /dev/null @@ -1,682 +0,0 @@ -"""Example 10: abandoning submissions and requests.""" - -from unittest import TestCase, mock -import tempfile -from datetime import datetime -from pytz import UTC - -from flask import Flask - -from ...services import classic -from ... import save, load, load_fast, domain, exceptions, core - -CCO = 'http://creativecommons.org/publicdomain/zero/1.0/' -TEX = domain.submission.SubmissionContent.Format('tex') - - -class TestAbandonSubmission(TestCase): - """Submitter has started a submission.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults) - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_abandon_new_submission(self): - """Submitter abandons new submission.""" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.DELETED, - "The submission is DELETED.") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.DELETED, - "The submission is DELETED.") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 1, - "There are one rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.USER_DELETED, - "The first row is USER_DELETED") - - -class TestAbandonReplacement(TestCase): - """Submitter has started a replacement and then rolled it back.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - self.submission, self.events = save( - domain.event.Rollback(**self.defaults), - submission_id=self.submission.submission_id - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_abandon_replacement_submission(self): - """The replacement is cancelled.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - self.assertEqual(submission.version, 1, "Back to v1") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - self.assertEqual(submission.version, 1, "Back to v1") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_can_start_new_replacement(self): - """The user can start a new replacement.""" - with self.app.app_context(): - submission, events = save( - domain.event.CreateSubmissionVersion(**self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is WORKING.") - self.assertEqual(submission.version, 2, "On to v2") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.WORKING, - "The submission is WORKING.") - self.assertEqual(submission.version, 2, "On to v2") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are three rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.REPLACEMENT, - "The second row has type 'replacement'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - self.assertEqual(db_rows[2].type, - classic.models.Submission.REPLACEMENT, - "The third row has type 'replacement'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.NOT_SUBMITTED, - "The third row is NOT_SUBMITTED") - - -class TestCrossListCancelled(TestCase): - """Submitter has created and cancelled a cross-list request.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - # Request cross-list classification - category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestCrossList(categories=[category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - request_id = self.submission.active_user_requests[0].request_id - self.submission, self.events = save( - domain.event.CancelRequest(request_id=request_id, - **self.defaults), - submission_id=self.submission.submission_id - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_request_is_cancelled(self): - """Submitter has cancelled the cross-list request.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_user_can_make_another_request(self): - """User can now make another request.""" - # Request cross-list classification - category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestCrossList(categories=[category], - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.CROSS_LIST, - "The second row has type 'cross'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - self.assertEqual(db_rows[2].type, - classic.models.Submission.CROSS_LIST, - "The third row has type 'cross'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is PROCESSING_SUBMISSION") - - -class TestWithdrawalCancelled(TestCase): - """Submitter has created and cancelled a withdrawal request.""" - - @classmethod - def setUpClass(cls): - """Instantiate an app for use with a SQLite database.""" - _, db = tempfile.mkstemp(suffix='.sqlite') - cls.app = Flask('foo') - cls.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{db}' - cls.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with cls.app.app_context(): - classic.init_app(cls.app) - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create, complete, and publish the submission.""" - self.submitter = domain.agent.User(1234, email='j.user@somewhere.edu', - forename='Jane', surname='User', - endorsements=['cs.DL', 'cs.IR']) - self.defaults = {'creator': self.submitter} - with self.app.app_context(): - classic.create_all() - self.title = "the best title" - self.doi = "10.01234/56789" - self.category = "cs.DL" - self.submission, self.events = save( - domain.event.CreateSubmission(**self.defaults), - domain.event.ConfirmContactInformation(**self.defaults), - domain.event.ConfirmAuthorship(**self.defaults), - domain.event.ConfirmPolicy(**self.defaults), - domain.event.SetTitle(title=self.title, **self.defaults), - domain.event.SetLicense(license_uri=CCO, - license_name="CC0 1.0", - **self.defaults), - domain.event.SetPrimaryClassification(category=self.category, - **self.defaults), - domain.event.SetUploadPackage(checksum="a9s9k342900ks03330029", - source_format=TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=593992, - **self.defaults), - domain.event.SetAbstract(abstract="Very abstract " * 20, - **self.defaults), - domain.event.SetComments(comments="Fine indeed " * 10, - **self.defaults), - domain.event.SetJournalReference(journal_ref="Foo 1992", - **self.defaults), - domain.event.SetDOI(doi=self.doi, **self.defaults), - domain.event.SetAuthors(authors_display='Robert Paulson (FC)', - **self.defaults), - domain.event.FinalizeSubmission(**self.defaults) - ) - - # Announce the submission. - self.paper_id = '1901.00123' - with self.app.app_context(): - session = classic.current_session() - db_row = session.query(classic.models.Submission).first() - db_row.status = classic.models.Submission.ANNOUNCED - dated = (datetime.now() - datetime.utcfromtimestamp(0)) - db_row.document = classic.models.Document( - document_id=1, - paper_id=self.paper_id, - title=self.submission.metadata.title, - authors=self.submission.metadata.authors_display, - dated=dated.total_seconds(), - primary_subject_class=self.category, - created=datetime.now(UTC), - submitter_email=self.submission.creator.email, - submitter_id=self.submission.creator.native_id - ) - db_row.doc_paper_id = self.paper_id - session.add(db_row) - session.commit() - - # Request cross-list classification - category = "cs.IR" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestWithdrawal(reason='A good reason', - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - request_id = self.submission.active_user_requests[0].request_id - self.submission, self.events = save( - domain.event.CancelRequest(request_id=request_id, - **self.defaults), - submission_id=self.submission.submission_id - ) - - def tearDown(self): - """Clear the database after each test.""" - with self.app.app_context(): - classic.drop_all() - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_request_is_cancelled(self): - """Submitter has cancelled the withdrawal request.""" - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 2, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.WITHDRAWAL, - "The second row has type 'wdr'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - - @mock.patch(f'{core.__name__}.StreamPublisher', mock.MagicMock()) - def test_user_can_make_another_request(self): - """User can now make another request.""" - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestWithdrawal(reason='A better reason', - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - submission, events = load(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - with self.app.app_context(): - submission = load_fast(self.submission.submission_id) - self.assertEqual(submission.status, - domain.submission.Submission.ANNOUNCED, - "The submission is ANNOUNCED.") - - # Check the database state. - with self.app.app_context(): - session = classic.current_session() - db_rows = session.query(classic.models.Submission) \ - .order_by(classic.models.Submission.submission_id.asc()) \ - .all() - - self.assertEqual(len(db_rows), 3, - "There are two rows in the submission table") - self.assertEqual(db_rows[0].type, - classic.models.Submission.NEW_SUBMISSION, - "The first row has type 'new'") - self.assertEqual(db_rows[0].status, - classic.models.Submission.ANNOUNCED, - "The first row is ANNOUNCED") - self.assertEqual(db_rows[1].type, - classic.models.Submission.WITHDRAWAL, - "The second row has type 'wdr'") - self.assertEqual(db_rows[1].status, - classic.models.Submission.USER_DELETED, - "The second row is USER_DELETED") - self.assertEqual(db_rows[2].type, - classic.models.Submission.WITHDRAWAL, - "The third row has type 'wdr'") - self.assertEqual(db_rows[2].status, - classic.models.Submission.PROCESSING_SUBMISSION, - "The third row is PROCESSING_SUBMISSION") - - with self.app.app_context(): - request_id = self.submission.active_user_requests[-1].request_id - self.submission, self.events = save( - domain.event.CancelRequest(request_id=request_id, - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - self.submission, self.events = save( - domain.event.RequestWithdrawal(reason='A better reason', - **self.defaults), - submission_id=self.submission.submission_id - ) - - with self.app.app_context(): - request_id = self.submission.active_user_requests[-1].request_id - self.submission, self.events = save( - domain.event.CancelRequest(request_id=request_id, - **self.defaults), - submission_id=self.submission.submission_id - ) - submission, events = load(self.submission.submission_id) - self.assertEqual(len(submission.active_user_requests), 0) diff --git a/src/arxiv/submission/tests/schedule/test_schedule.py b/src/arxiv/submission/tests/schedule/test_schedule.py deleted file mode 100644 index f782980..0000000 --- a/src/arxiv/submission/tests/schedule/test_schedule.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Tests for :mod:`.schedule`.""" - -from unittest import TestCase -from datetime import datetime, timedelta -from pytz import timezone, UTC -from ... import schedule - -ET = timezone('US/Eastern') - - -class TestSchedule(TestCase): - """Verify that scheduling functions work as expected.""" - - def test_monday_morning(self): - """E-print was submitted on Monday morning.""" - submitted = ET.localize(datetime(2019, 3, 18, 9, 47, 0)) - self.assertEqual(schedule.next_announcement_time(submitted), - ET.localize(datetime(2019, 3, 18, 20, 0, 0)), - "Will be announced at 8pm this evening") - self.assertEqual(schedule.next_freeze_time(submitted), - ET.localize(datetime(2019, 3, 18, 14, 0, 0)), - "Freeze time is 2pm this afternoon") - - def test_monday_late_afternoon(self): - """E-print was submitted on Monday in the late afternoon.""" - submitted = ET.localize(datetime(2019, 3, 18, 15, 32, 0)) - self.assertEqual(schedule.next_announcement_time(submitted), - ET.localize(datetime(2019, 3, 19, 20, 0, 0)), - "Will be announced at 8pm tomorrow evening") - self.assertEqual(schedule.next_freeze_time(submitted), - ET.localize(datetime(2019, 3, 19, 14, 0, 0)), - "Freeze time is 2pm tomorrow afternoon") - - def test_monday_evening(self): - """E-print was submitted on Monday in the evening.""" - submitted = ET.localize(datetime(2019, 3, 18, 22, 32, 0)) - self.assertEqual(schedule.next_announcement_time(submitted), - ET.localize(datetime(2019, 3, 19, 20, 0, 0)), - "Will be announced at 8pm tomorrow evening") - self.assertEqual(schedule.next_freeze_time(submitted), - ET.localize(datetime(2019, 3, 19, 14, 0, 0)), - "Freeze time is 2pm tomorrow afternoon") - - def test_saturday(self): - """E-print was submitted on a Saturday.""" - submitted = ET.localize(datetime(2019, 3, 23, 22, 32, 0)) - self.assertEqual(schedule.next_announcement_time(submitted), - ET.localize(datetime(2019, 3, 25, 20, 0, 0)), - "Will be announced at 8pm next Monday") - self.assertEqual(schedule.next_freeze_time(submitted), - ET.localize(datetime(2019, 3, 25, 14, 0, 0)), - "Freeze time is 2pm next Monday") - - def test_friday_afternoon(self): - """E-print was submitted on a Friday in the early afternoon.""" - submitted = ET.localize(datetime(2019, 3, 22, 13, 32, 0)) - self.assertEqual(schedule.next_announcement_time(submitted), - ET.localize(datetime(2019, 3, 24, 20, 0, 0)), - "Will be announced at 8pm on Sunday") - self.assertEqual(schedule.next_freeze_time(submitted), - ET.localize(datetime(2019, 3, 22, 14, 0, 0)), - "Freeze time is 2pm that same day") diff --git a/src/arxiv/submission/tests/serializer/test_serializer.py b/src/arxiv/submission/tests/serializer/test_serializer.py deleted file mode 100644 index f1c48e3..0000000 --- a/src/arxiv/submission/tests/serializer/test_serializer.py +++ /dev/null @@ -1,151 +0,0 @@ -from unittest import TestCase -from datetime import datetime -from pytz import UTC -from dataclasses import asdict -import json - -from ...serializer import dumps, loads -from ...domain.event import CreateSubmission, SetTitle -from ...domain.agent import User, System, Client -from ...domain.submission import Submission, SubmissionContent, License, \ - Classification, CrossListClassificationRequest, Hold, Waiver -from ...domain.proposal import Proposal -from ...domain.process import ProcessStatus -from ...domain.annotation import Feature, Comment -from ...domain.flag import ContentFlag - - -class TestDumpLoad(TestCase): - """Tests for :func:`.dumps` and :func:`.loads`.""" - - def test_dump_createsubmission(self): - """Serialize and deserialize a :class:`.CreateSubmission` event.""" - user = User('123', 'foo@user.com', 'foouser') - event = CreateSubmission(creator=user, created=datetime.now(UTC)) - data = dumps(event) - self.assertDictEqual(asdict(user), json.loads(data)["creator"], - "User data is fully encoded") - deserialized = loads(data) - self.assertEqual(deserialized, event) - self.assertEqual(deserialized.creator, user) - self.assertEqual(deserialized.created, event.created) - - def test_dump_load_submission(self): - """Serialize and deserialize a :class:`.Submission`.""" - user = User('123', 'foo@user.com', 'foouser') - - client = Client('fooclient', 'asdf') - system = System('testprocess') - submission = Submission( - creator=user, - owner=user, - client=client, - created=datetime.now(UTC), - updated=datetime.now(UTC), - submitted=datetime.now(UTC), - source_content=SubmissionContent( - identifier='12345', - checksum='asdf1234', - uncompressed_size=435321, - compressed_size=23421, - source_format=SubmissionContent.Format.TEX - ), - primary_classification=Classification(category='cs.DL'), - secondary_classification=[Classification(category='cs.AI')], - submitter_contact_verified=True, - submitter_is_author=True, - submitter_accepts_policy=True, - submitter_confirmed_preview=True, - license=License('http://foolicense.org/v1', 'The Foo License'), - status=Submission.ANNOUNCED, - arxiv_id='1234.56789', - version=2, - user_requests={ - 'asdf1234': CrossListClassificationRequest('asdf1234', user) - }, - proposals={ - 'prop1234': Proposal( - event_id='prop1234', - creator=user, - proposed_event_type=SetTitle, - proposed_event_data={'title': 'foo title'} - ) - }, - processes=[ - ProcessStatus( - creator=system, - created=datetime.now(UTC), - status=ProcessStatus.Status.SUCCEEDED, - process='FooProcess' - ) - ], - annotations={ - 'asdf123543': Feature( - event_id='asdf123543', - created=datetime.now(UTC), - creator=system, - feature_type=Feature.Type.PAGE_COUNT, - feature_value=12345678.32 - ) - }, - flags={ - 'fooflag1': ContentFlag( - event_id='fooflag1', - creator=system, - created=datetime.now(UTC), - flag_type=ContentFlag.FlagType.LOW_STOP, - flag_data=25, - comment='no comment' - ) - }, - comments={ - 'asdf54321': Comment( - event_id='asdf54321', - creator=system, - created=datetime.now(UTC), - body='here is comment' - ) - }, - holds={ - 'foohold1234': Hold( - event_id='foohold1234', - creator=system, - hold_type=Hold.Type.SOURCE_OVERSIZE, - hold_reason='the best reason' - ) - }, - waivers={ - 'waiver1234': Waiver( - event_id='waiver1234', - waiver_type=Hold.Type.SOURCE_OVERSIZE, - waiver_reason='it is ok', - created=datetime.now(UTC), - creator=system - ) - } - ) - raw = dumps(submission) - loaded = loads(raw) - - self.assertEqual(submission.creator, loaded.creator) - self.assertEqual(submission.owner, loaded.owner) - self.assertEqual(submission.client, loaded.client) - self.assertEqual(submission.created, loaded.created) - self.assertEqual(submission.updated, loaded.updated) - self.assertEqual(submission.submitted, loaded.submitted) - self.assertEqual(submission.source_content, loaded.source_content) - self.assertEqual(submission.source_content.source_format, - loaded.source_content.source_format) - self.assertEqual(submission.primary_classification, - loaded.primary_classification) - self.assertEqual(submission.secondary_classification, - loaded.secondary_classification) - self.assertEqual(submission.license, loaded.license) - self.assertEqual(submission.user_requests, loaded.user_requests) - self.assertEqual(submission.proposals, loaded.proposals) - self.assertEqual(submission.processes, loaded.processes) - self.assertEqual(submission.annotations, loaded.annotations) - self.assertEqual(submission.flags, loaded.flags) - self.assertEqual(submission.comments, loaded.comments) - self.assertEqual(submission.holds, loaded.holds) - self.assertEqual(submission.waivers, loaded.waivers) diff --git a/src/arxiv/submission/tests/util.py b/src/arxiv/submission/tests/util.py deleted file mode 100644 index 7a32341..0000000 --- a/src/arxiv/submission/tests/util.py +++ /dev/null @@ -1,74 +0,0 @@ -import uuid -from contextlib import contextmanager -from datetime import datetime, timedelta -from typing import Optional, List - -from arxiv_auth import domain -from arxiv_auth.auth import tokens -from flask import Flask -from pytz import UTC - -from ..services import classic - - -@contextmanager -def in_memory_db(app: Optional[Flask] = None): - """Provide an in-memory sqlite database for testing purposes.""" - if app is None: - app = Flask('foo') - app.config['CLASSIC_DATABASE_URI'] = 'sqlite://' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - - with app.app_context(): - classic.init_app(app) - classic.create_all() - try: - yield classic.current_session() - except Exception: - raise - finally: - classic.drop_all() - - -# Generate authentication token -def generate_token(app: Flask, scope: List[str]) -> str: - """Helper function for generating a JWT.""" - secret = app.config.get('JWT_SECRET') - start = datetime.now(tz=UTC) - end = start + timedelta(seconds=36000) # Make this as long as you want. - user_id = '1' - email = 'foo@bar.com' - username = 'theuser' - first_name = 'Jane' - last_name = 'Doe' - suffix_name = 'IV' - affiliation = 'Cornell University' - rank = 3 - country = 'us' - default_category = 'astro-ph.GA' - submission_groups = 'grp_physics' - endorsements = 'astro-ph.CO,astro-ph.GA' - session = domain.Session( - session_id=str(uuid.uuid4()), - start_time=start, end_time=end, - user=domain.User( - user_id=user_id, - email=email, - username=username, - name=domain.UserFullName(first_name, last_name, suffix_name), - profile=domain.UserProfile( - affiliation=affiliation, - rank=int(rank), - country=country, - default_category=domain.Category(default_category), - submission_groups=submission_groups.split(',') - ) - ), - authorizations=domain.Authorizations( - scopes=scope, - endorsements=[domain.Category(cat.split('.', 1)) - for cat in endorsements.split(',')] - ) - ) - token = tokens.encode(session, secret) - return token \ No newline at end of file diff --git a/src/arxiv/submit_fastapi/api/models/__init__.py b/src/arxiv/submit_fastapi/api/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/arxiv/submit_fastapi/config.py b/src/arxiv/submit_fastapi/config.py deleted file mode 100644 index 54435c0..0000000 --- a/src/arxiv/submit_fastapi/config.py +++ /dev/null @@ -1,34 +0,0 @@ -import secrets -from typing import List, Union - -from pydantic_settings import BaseSettings - -from pydantic import SecretStr, PyObject - - - -class Settings(BaseSettings): - classic_db_uri: str = 'mysql://not-set-check-config/0000' - """arXiv legacy DB URL.""" - - jwt_secret: SecretStr = "not-set-" + secrets.token_urlsafe(16) - """NG JWT_SECRET from arxiv-auth login service""" - - submission_api_implementation: PyObject = 'arxiv.submit_fastapi.api.legacy_implementation.LegacySubmitImplementation' - """Class to use for submission API implementation.""" - - submission_api_implementation_depends_function: PyObject = 'arxiv.submit_fastapi.api.legacy_implementation.legacy_depends' - """Function to depend on submission API implementation.""" - - - class Config: - env_file = "env" - """File to read environment from""" - - case_sensitive = False - - -config = Settings() -"""Settings build from defaults, env file, and env vars. - -Environment vars have the highest precedence, defaults the lowest.""" diff --git a/submit/.python-version b/submit/.python-version new file mode 100644 index 0000000..b78aae2 --- /dev/null +++ b/submit/.python-version @@ -0,0 +1 @@ +submission-ui diff --git a/submit/__init__.py b/submit/__init__.py new file mode 100644 index 0000000..6d49e6c --- /dev/null +++ b/submit/__init__.py @@ -0,0 +1 @@ +"""Main submit UI.""" diff --git a/submit/config.py b/submit/config.py new file mode 100644 index 0000000..dd0c9ee --- /dev/null +++ b/submit/config.py @@ -0,0 +1,386 @@ +f""" +Flask configuration. + +Docstrings are from the `Flask configuration documentation +`_. +""" +from typing import Optional +import warnings +from os import environ + +APP_VERSION = "0.1.1-alpha" +"""The current version of this application.""" + +NAMESPACE = environ.get('NAMESPACE') +"""Namespace in which this service is deployed; to qualify keys for secrets.""" + +APPLICATION_ROOT = environ.get('APPLICATION_ROOT', '/') +"""Path where application is deployed.""" + +SITE_URL_PREFIX = environ.get('APPLICATION_ROOT', '/') + +# RELATIVE_STATIC_PATHS = True +RELATIVE_STATIC_PREFIX = environ.get('APPLICATION_ROOT', '') + +LOGLEVEL = int(environ.get('LOGLEVEL', '20')) +""" +Logging verbosity. + +See `https://docs.python.org/3/library/logging.html#levels`_. +""" + +JWT_SECRET = environ.get('JWT_SECRET') +"""Secret key for signing + verifying authentication JWTs.""" + +CSRF_SECRET = environ.get('FLASK_SECRET', 'csrfbarsecret') +"""Secret used for generating CSRF tokens.""" + +if not JWT_SECRET: + warnings.warn('JWT_SECRET is not set; authn/z may not work correctly!') + + +WAIT_FOR_SERVICES = bool(int(environ.get('WAIT_FOR_SERVICES', '0'))) +"""Disable/enable waiting for upstream services to be available on startup.""" +if not WAIT_FOR_SERVICES: + warnings.warn('Awaiting upstream services is disabled; this should' + ' probably be enabled in production.') + +WAIT_ON_STARTUP = int(environ.get('WAIT_ON_STARTUP', '0')) +"""Number of seconds to wait before checking upstream services on startup.""" + +ENABLE_CALLBACKS = bool(int(environ.get('ENABLE_CALLBACKS', '1'))) +"""Enable/disable the :func:`Event.bind` feature.""" + +SESSION_COOKIE_NAME = 'submission_ui_session' +"""Cookie used to store ui-app-related information.""" + + +# --- FLASK CONFIGURATION --- + +DEBUG = bool(int(environ.get('DEBUG', '0'))) +"""enable/disable debug mode""" + +TESTING = bool(int(environ.get('TESTING', '0'))) +"""enable/disable testing mode""" + +SECRET_KEY = environ.get('FLASK_SECRET', 'fooflasksecret') +"""Flask secret key.""" + +PROPAGATE_EXCEPTIONS = \ + True if bool(int(environ.get('PROPAGATE_EXCEPTIONS', '0'))) else None +""" +explicitly enable or disable the propagation of exceptions. If not set or +explicitly set to None this is implicitly true if either TESTING or DEBUG is +true. +""" + +PRESERVE_CONTEXT_ON_EXCEPTION: Optional[bool] = None +""" +By default if the application is in debug mode the request context is not +popped on exceptions to enable debuggers to introspect the data. This can be +disabled by this key. You can also use this setting to force-enable it for non +debug execution which might be useful to debug production applications (but +also very risky). +""" +if bool(int(environ.get('PRESERVE_CONTEXT_ON_EXCEPTION', '0'))): + PRESERVE_CONTEXT_ON_EXCEPTION = True + + +USE_X_SENDFILE = bool(int(environ.get('USE_X_SENDFILE', '0'))) +"""Enable/disable x-sendfile""" + +LOGGER_NAME = environ.get('LOGGER_NAME', 'search') +"""The name of the logger.""" + +LOGGER_HANDLER_POLICY = environ.get('LOGGER_HANDLER_POLICY', 'debug') +""" +the policy of the default logging handler. The default is 'always' which means +that the default logging handler is always active. 'debug' will only activate +logging in debug mode, 'production' will only log in production and 'never' +disables it entirely. +""" + +SERVER_NAME = None # "foohost:8000" #environ.get('SERVER_NAME', None) +""" +the name and port number of the server. Required for subdomain support +(e.g.: 'myapp.dev:5000') Note that localhost does not support subdomains so +setting this to 'localhost' does not help. Setting a SERVER_NAME also by +default enables URL generation without a request context but with an +application context. +""" + + +# --- DATABASE CONFIGURATION --- + +CLASSIC_DATABASE_URI = environ.get('CLASSIC_DATABASE_URI', 'sqlite:///') +"""Full database URI for the classic system.""" + +SQLALCHEMY_DATABASE_URI = CLASSIC_DATABASE_URI +"""Full database URI for the classic system.""" + +SQLALCHEMY_TRACK_MODIFICATIONS = False +"""Track modifications feature should always be disabled.""" + +# Integration with the preview service. +PREVIEW_HOST = environ.get('PREVIEW_SERVICE_HOST', 'localhost') +"""Hostname or address of the preview service.""" + +PREVIEW_PORT = environ.get('PREVIEW_SERVICE_PORT', '8000') +"""Port for the preview service.""" + +PREVIEW_PROTO = environ.get( + f'PREVIEW_PORT_{PREVIEW_PORT}_PROTO', + environ.get('PREVIEW_PROTO', 'http') +) +"""Protocol for the preview service.""" + +PREVIEW_PATH = environ.get('PREVIEW_PATH', '') +"""Path at which the preview service is deployed.""" + +PREVIEW_ENDPOINT = environ.get( + 'PREVIEW_ENDPOINT', + '%s://%s:%s/%s' % (PREVIEW_PROTO, PREVIEW_HOST, PREVIEW_PORT, PREVIEW_PATH) +) +""" +Full URL to the root preview service API endpoint. + +If not explicitly provided, this is composed from :const:`PREVIEW_HOST`, +:const:`PREVIEW_PORT`, :const:`PREVIEW_PROTO`, +and :const:`PREVIEW_PATH`. +""" + +PREVIEW_VERIFY = bool(int(environ.get('PREVIEW_VERIFY', '0'))) +"""Enable/disable SSL certificate verification for preview service.""" + +PREVIEW_STATUS_TIMEOUT = float(environ.get('PREVIEW_STATUS_TIMEOUT', 1.0)) + +if PREVIEW_PROTO == 'https' and not PREVIEW_VERIFY: + warnings.warn('Certificate verification for preview service is disabled;' + ' this should not be disabled in production.') + + +# Integration with the file manager service. +FILEMANAGER_HOST = environ.get('FILEMANAGER_SERVICE_HOST', 'arxiv.org') +"""Hostname or addreess of the filemanager service.""" + +FILEMANAGER_PORT = environ.get('FILEMANAGER_SERVICE_PORT', '443') +"""Port for the filemanager service.""" + +FILEMANAGER_PROTO = environ.get(f'FILEMANAGER_PORT_{FILEMANAGER_PORT}_PROTO', + environ.get('FILEMANAGER_PROTO', 'https')) +"""Protocol for the filemanager service.""" + +FILEMANAGER_PATH = environ.get('FILEMANAGER_PATH', '').lstrip('/') +"""Path at which the filemanager service is deployed.""" + +FILEMANAGER_ENDPOINT = environ.get( + 'FILEMANAGER_ENDPOINT', + '%s://%s:%s/%s' % (FILEMANAGER_PROTO, FILEMANAGER_HOST, + FILEMANAGER_PORT, FILEMANAGER_PATH) +) +""" +Full URL to the root filemanager service API endpoint. + +If not explicitly provided, this is composed from :const:`FILEMANAGER_HOST`, +:const:`FILEMANAGER_PORT`, :const:`FILEMANAGER_PROTO`, and +:const:`FILEMANAGER_PATH`. +""" + +FILEMANAGER_VERIFY = bool(int(environ.get('FILEMANAGER_VERIFY', '1'))) +"""Enable/disable SSL certificate verification for filemanager service.""" + +FILEMANAGER_STATUS_ENDPOINT = environ.get('FILEMANAGER_STATUS_ENDPOINT', + 'status') +"""Path to the file manager service status endpoint.""" + +FILEMANAGER_STATUS_TIMEOUT \ + = float(environ.get('FILEMANAGER_STATUS_TIMEOUT', 1.0)) + +if FILEMANAGER_PROTO == 'https' and not FILEMANAGER_VERIFY: + warnings.warn('Certificate verification for filemanager is disabled; this' + ' should not be disabled in production.') + + +# Integration with the compiler service. +COMPILER_HOST = environ.get('COMPILER_SERVICE_HOST', 'arxiv.org') +"""Hostname or addreess of the compiler service.""" + +COMPILER_PORT = environ.get('COMPILER_SERVICE_PORT', '443') +"""Port for the compiler service.""" + +COMPILER_PROTO = environ.get(f'COMPILER_PORT_{COMPILER_PORT}_PROTO', + environ.get('COMPILER_PROTO', 'https')) +"""Protocol for the compiler service.""" + +COMPILER_PATH = environ.get('COMPILER_PATH', '') +"""Path at which the compiler service is deployed.""" + +COMPILER_ENDPOINT = environ.get( + 'COMPILER_ENDPOINT', + '%s://%s:%s/%s' % (COMPILER_PROTO, COMPILER_HOST, COMPILER_PORT, + COMPILER_PATH) +) +""" +Full URL to the root compiler service API endpoint. + +If not explicitly provided, this is composed from :const:`COMPILER_HOST`, +:const:`COMPILER_PORT`, :const:`COMPILER_PROTO`, and :const:`COMPILER_PATH`. +""" + +COMPILER_STATUS_TIMEOUT \ + = float(environ.get('COMPILER_STATUS_TIMEOUT', 1.0)) + +COMPILER_VERIFY = bool(int(environ.get('COMPILER_VERIFY', '1'))) +"""Enable/disable SSL certificate verification for compiler service.""" + +if COMPILER_PROTO == 'https' and not COMPILER_VERIFY: + warnings.warn('Certificate verification for compiler is disabled; this' + ' should not be disabled in production.') + + +EXTERNAL_URL_SCHEME = environ.get('EXTERNAL_URL_SCHEME', 'https') +BASE_SERVER = environ.get('BASE_SERVER', 'arxiv.org') + +URLS = [ + ("help_license", "/help/license", BASE_SERVER), + ("help_third_party_submission", "/help/third_party_submission", + BASE_SERVER), + ("help_cross", "/help/cross", BASE_SERVER), + ("help_submit", "/help/submit", BASE_SERVER), + ("help_ancillary_files", "/help/ancillary_files", BASE_SERVER), + ("help_texlive", "/help/faq/texlive", BASE_SERVER), + ("help_whytex", "/help/faq/whytex", BASE_SERVER), + ("help_default_packages", "/help/submit_tex#wegotem", BASE_SERVER), + ("help_submit_tex", "/help/submit_tex", BASE_SERVER), + ("help_submit_pdf", "/help/submit_pdf", BASE_SERVER), + ("help_submit_ps", "/help/submit_ps", BASE_SERVER), + ("help_submit_html", "/help/submit_html", BASE_SERVER), + ("help_submit_sizes", "/help/sizes", BASE_SERVER), + ("help_metadata", "/help/prep", BASE_SERVER), + ("help_jref", "/help/jref", BASE_SERVER), + ("help_withdraw", "/help/withdraw", BASE_SERVER), + ("help_replace", "/help/replace", BASE_SERVER), + ("help_endorse", "/help/endorsement", BASE_SERVER), + ("clickthrough", "/ct?url=&v=", BASE_SERVER), + ("help_endorse", "/help/endorsement", BASE_SERVER), + ("help_replace", "/help/replace", BASE_SERVER), + ("help_version", "/help/replace#versions", BASE_SERVER), + ("help_email", "/help/email-protection", BASE_SERVER), + ("help_author", "/help/prep#author", BASE_SERVER), + ("help_mistakes", "/help/faq/mistakes", BASE_SERVER), + ("help_texprobs", "/help/faq/texprobs", BASE_SERVER), + ("login", "/user/login", BASE_SERVER) +] +""" +URLs for external services, for use with :func:`flask.url_for`. +This subset of URLs is common only within submit, for now - maybe move to base +if these pages seem relevant to other services. + +For details, see :mod:`arxiv.base.urls`. +""" + +AUTH_UPDATED_SESSION_REF = True +""" +Authn/z info is at ``request.auth`` instead of ``request.session``. + +See `https://arxiv-org.atlassian.net/browse/ARXIVNG-2186`_. +""" + +# --- AWS CONFIGURATION --- + +AWS_ACCESS_KEY_ID = environ.get('AWS_ACCESS_KEY_ID', 'nope') +""" +Access key for requests to AWS services. + +If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. +""" + +AWS_SECRET_ACCESS_KEY = environ.get('AWS_SECRET_ACCESS_KEY', 'nope') +""" +Secret auth key for requests to AWS services. + +If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. +""" + +AWS_REGION = environ.get('AWS_REGION', 'us-east-1') +"""Default region for calling AWS services.""" + + +# --- KINESIS CONFIGURATION --- + +KINESIS_STREAM = environ.get("KINESIS_STREAM", "SubmissionEvents") +"""Name of the stream on which to produce and consume events.""" + +KINESIS_SHARD_ID = environ.get("KINESIS_SHARD_ID", "0") +""" +Shard ID for this agent instance. + +There must only be one agent process running per shard. +""" + +KINESIS_START_TYPE = environ.get("KINESIS_START_TYPE", "TRIM_HORIZON") +"""Start type to use when no checkpoint is available.""" + +KINESIS_ENDPOINT = environ.get("KINESIS_ENDPOINT", None) +""" +Alternate endpoint for connecting to Kinesis. + +If ``None``, uses the boto3 defaults for the :const:`AWS_REGION`. This is here +mainly to support development with localstack or other mocking frameworks. +""" + +KINESIS_VERIFY = bool(int(environ.get("KINESIS_VERIFY", "1"))) +""" +Enable/disable TLS certificate verification when connecting to Kinesis. + +This is here support development with localstack or other mocking frameworks. +""" + +if not KINESIS_VERIFY: + warnings.warn('Certificate verification for Kinesis is disabled; this' + ' should not be disabled in production.') + + +# --- VAULT INTEGRATION CONFIGURATION --- + +VAULT_ENABLED = bool(int(environ.get('VAULT_ENABLED', '0'))) +"""Enable/disable secret retrieval from Vault.""" + +KUBE_TOKEN = environ.get('KUBE_TOKEN', 'fookubetoken') +"""Service account token for authenticating with Vault. May be a file path.""" + +VAULT_HOST = environ.get('VAULT_HOST', 'foovaulthost') +"""Vault hostname/address.""" + +VAULT_PORT = environ.get('VAULT_PORT', '1234') +"""Vault API port.""" + +VAULT_ROLE = environ.get('VAULT_ROLE', 'ui-app-ui') +"""Vault role linked to this application's service account.""" + +VAULT_CERT = environ.get('VAULT_CERT') +"""Path to CA certificate for TLS verification when talking to Vault.""" + +VAULT_SCHEME = environ.get('VAULT_SCHEME', 'https') +"""Default is ``https``.""" + +NS_AFFIX = '' if NAMESPACE == 'production' else f'-{NAMESPACE}' +VAULT_REQUESTS = [ + {'type': 'generic', + 'name': 'JWT_SECRET', + 'mount_point': f'secret{NS_AFFIX}/', + 'path': 'jwt', + 'key': 'jwt-secret', + 'minimum_ttl': 3600}, + {'type': 'aws', + 'name': 'AWS_S3_CREDENTIAL', + 'mount_point': f'aws{NS_AFFIX}/', + 'role': environ.get('VAULT_CREDENTIAL')}, + {'type': 'generic', + 'name': 'SQLALCHEMY_DATABASE_URI', + 'mount_point': f'secret{NS_AFFIX}/', + 'path': 'beta-mysql', + 'key': 'uri', + 'minimum_ttl': 360000}, +] +"""Requests for Vault secrets.""" diff --git a/src/arxiv/__init__.py b/submit/controllers/__init__.py similarity index 100% rename from src/arxiv/__init__.py rename to submit/controllers/__init__.py diff --git a/src/arxiv/submission/domain/event/tests/__init__.py b/submit/controllers/api/__init__.py similarity index 100% rename from src/arxiv/submission/domain/event/tests/__init__.py rename to submit/controllers/api/__init__.py diff --git a/submit/controllers/ui/__init__.py b/submit/controllers/ui/__init__.py new file mode 100644 index 0000000..09d7969 --- /dev/null +++ b/submit/controllers/ui/__init__.py @@ -0,0 +1,55 @@ +"""Request controllers for the ui-app UI.""" + +from typing import Tuple, Dict, Any + +from arxiv_auth.domain import Session +from werkzeug.datastructures import MultiDict + +from http import HTTPStatus as status + +from . import util, jref, withdraw, delete, cross + +from .new.authorship import authorship +from .new.classification import classification, cross_list +from .new.create import create +from .new.final import finalize +from .new.license import license +from .new.metadata import metadata +from .new.metadata import optional +from .new.policy import policy +from .new.verify_user import verify +from .new.unsubmit import unsubmit + +from .new import process +from .new import upload + +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, advance_to_current + +from .util import Response + + +# def submission_status(method: str, params: MultiDict, session: Session, +# submission_id: int) -> Response: +# user, client = util.user_and_client_from_session(session) + +# # Will raise NotFound if there is no such ui-app. +# ui-app, submission_events = load_submission(submission_id) +# response_data = { +# 'ui-app': ui-app, +# 'submission_id': submission_id, +# 'events': submission_events +# } +# return response_data, status.OK, {} + + +def submission_edit(method: str, params: MultiDict, session: Session, + submission_id: int) -> Response: + """Cause flow_control to go to the current_stage of the Submission.""" + submission, submission_events = load_submission(submission_id) + response_data = { + 'ui-app': submission, + 'submission_id': submission_id, + 'events': submission_events, + } + return advance_to_current((response_data, status.OK, {})) diff --git a/submit/controllers/ui/cross.py b/submit/controllers/ui/cross.py new file mode 100644 index 0000000..1427042 --- /dev/null +++ b/submit/controllers/ui/cross.py @@ -0,0 +1,211 @@ +"""Controller for cross-list requests.""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional, List + +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form, widgets +from wtforms.fields import Field, BooleanField, HiddenField +from wtforms.validators import InputRequired, ValidationError, optional, \ + DataRequired + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv.submission import save, Submission +from arxiv.submission.domain.event import RequestCrossList +from arxiv.submission.exceptions import SaveError +from arxiv.taxonomy import CATEGORIES_ACTIVE as CATEGORIES +from arxiv.taxonomy import ARCHIVES_ACTIVE as ARCHIVES +from arxiv_auth.domain import Session + +from ...util import load_submission +from .util import user_and_client_from_session, OptGroupSelectField, \ + validate_command + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +CONTACT_SUPPORT = Markup( + 'If you continue to experience problems, please contact' + ' arXiv support.' +) + + +class HiddenListField(HiddenField): + def process_formdata(self, valuelist): + self.data = list(str(x) for x in valuelist if x) + + def process_data(self, value): + try: + self.data = list(str(v) for v in value if v) + except (ValueError, TypeError): + self.data = None + + def _value(self): + return ",".join(self.data) if self.data else "" + + +class CrossListForm(csrf.CSRFForm): + """Submit a cross-list request.""" + + CATEGORIES = [ + (archive['name'], [ + (category_id, f"{category['name']} ({category_id})") + for category_id, category in CATEGORIES.items() + if category['in_archive'] == archive_id + ]) + for archive_id, archive in ARCHIVES.items() + ] + """Categories grouped by archive.""" + + ADD = 'add' + REMOVE = 'remove' + OPERATIONS = [ + (ADD, 'Add'), + (REMOVE, 'Remove') + ] + operation = HiddenField(default=ADD, validators=[optional()]) + category = OptGroupSelectField('Category', choices=CATEGORIES, + default='', validators=[optional()]) + selected = HiddenListField() + confirmed = BooleanField('Confirmed', + false_values=('false', False, 0, '0', '')) + + def validate_selected(form: csrf.CSRFForm, field: Field) -> None: + if form.confirmed.data and not field.data: + raise ValidationError('Please select a category') + for value in field.data: + if value not in CATEGORIES: + raise ValidationError('Not a valid category') + + def validate_category(form: csrf.CSRFForm, field: Field) -> None: + if not form.confirmed.data and not field.data: + raise ValidationError('Please select a category') + + def filter_choices(self, submission: Submission, session: Session, + exclude: Optional[List[str]] = None) -> None: + """Remove redundant choices, and limit to endorsed categories.""" + selected: List[str] = self.category.data + primary = submission.primary_classification + + choices = [ + (archive, [ + (category, display) for category, display in archive_choices + if (exclude is not None and category not in exclude + and (primary is None or category != primary.category) + and category not in submission.secondary_categories) + or category in selected + ]) + for archive, archive_choices in self.category.choices + ] + self.category.choices = [ + (archive, _choices) for archive, _choices in choices + if len(_choices) > 0 + ] + + @classmethod + def formset(cls, selected: List[str]) -> Dict[str, 'CrossListForm']: + """Generate a set of forms to add/remove categories in the template.""" + formset = {} + for category in selected: + if not category: + continue + subform = cls(operation=cls.REMOVE, category=category) + subform.category.widget = widgets.HiddenInput() + formset[category] = subform + return formset + + +def request_cross(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Request cross-list classification for an announced e-print.""" + submitter, client = user_and_client_from_session(session) + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + + # Will raise NotFound if there is no such ui-app. + submission, submission_events = load_submission(submission_id) + + # The ui-app must be announced for this to be a cross-list request. + if not submission.is_announced: + alerts.flash_failure( + Markup("Submission must first be announced. See the arXiv help" + " pages for details.")) + status_url = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': status_url} + + if method == 'GET': + params = MultiDict({}) + + params.setdefault("confirmed", False) + params.setdefault("operation", CrossListForm.ADD) + form = CrossListForm(params) + selected = [v for v in form.selected.data if v] + form.filter_choices(submission, session, exclude=selected) + + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'form': form, + 'selected': selected, + 'formset': CrossListForm.formset(selected) + } + if submission.primary_classification: + response_data['primary'] = \ + CATEGORIES[submission.primary_classification.category] + + if method == 'POST': + if not form.validate(): + raise BadRequest(response_data) + + if form.confirmed.data: # Stop adding new categories, and submit. + response_data['form'].operation.data = CrossListForm.ADD + response_data['require_confirmation'] = True + + command = RequestCrossList(creator=submitter, client=client, + categories=form.selected.data) + if not validate_command(form, command, submission, 'category'): + alerts.flash_failure(Markup( + "There was a problem with your request. Please try again." + f" {CONTACT_SUPPORT}" + )) + raise BadRequest(response_data) + + try: # Submit the cross-list request. + save(command, submission_id=submission_id) + except SaveError as e: + # This would be due to a database error, or something else + # that likely isn't the user's fault. + logger.error('Could not save cross list request event') + alerts.flash_failure(Markup( + "There was a problem processing your request. Please try" + f" again. {CONTACT_SUPPORT}" + )) + raise InternalServerError(response_data) from e + + # Success! Send user back to the ui-app page. + alerts.flash_success("Cross-list request submitted.") + status_url = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': status_url} + else: # User is adding or removing a category. + if form.operation.data: + if form.operation.data == CrossListForm.REMOVE: + selected.remove(form.category.data) + elif form.operation.data == CrossListForm.ADD: + selected.append(form.category.data) + # Update the "remove" formset to reflect the change. + response_data['formset'] = CrossListForm.formset(selected) + response_data['selected'] = selected + # Now that we've handled the request, get a fresh form for adding + # more categories or submitting the request. + response_data['form'] = CrossListForm() + response_data['form'].filter_choices(submission, session, + exclude=selected) + response_data['form'].operation.data = CrossListForm.ADD + response_data['require_confirmation'] = True + return response_data, status.OK, {} + return response_data, status.OK, {} diff --git a/submit/controllers/ui/delete.py b/submit/controllers/ui/delete.py new file mode 100644 index 0000000..ac69213 --- /dev/null +++ b/submit/controllers/ui/delete.py @@ -0,0 +1,133 @@ +"""Provides controllers used to delete/roll back a ui-app.""" + +from http import HTTPStatus as status +from typing import Optional + +from flask import url_for +from wtforms import BooleanField, validators +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import BadRequest, InternalServerError, NotFound + +from arxiv.base import logging, alerts +from arxiv.submission import save +from arxiv.submission.domain.event import Rollback, CancelRequest +from arxiv.submission.domain import WithdrawalRequest, \ + CrossListClassificationRequest, UserRequest +from arxiv.forms import csrf +from arxiv_auth.domain import Session +from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command +from submit.util import load_submission + + +class DeleteForm(csrf.CSRFForm): + """Form for deleting a ui-app or a revision.""" + + confirmed = BooleanField('Confirmed', + validators=[validators.DataRequired()]) + + +class CancelRequestForm(csrf.CSRFForm): + """Form for cancelling a request.""" + + confirmed = BooleanField('Confirmed', + validators=[validators.DataRequired()]) + + +def delete(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """ + Delete a ui-app, replacement, or other request. + + We never really DELETE-delete anything. The workhorse is + :class:`.Rollback`. For new submissions, this just makes the ui-app + inactive (disappear from user views). For replacements, or other kinds of + requests that happen after the first version is announced, the ui-app + is simply reverted back to the state of the last announcement. + + """ + submission, submission_events = load_submission(submission_id) + response_data = { + 'ui-app': submission, + 'submission_id': submission.submission_id, + } + + if method == 'GET': + form = DeleteForm() + response_data.update({'form': form}) + return response_data, status.OK, {} + elif method == 'POST': + form = DeleteForm(params) + response_data.update({'form': form}) + if form.validate() and form.confirmed.data: + user, client = user_and_client_from_session(session) + command = Rollback(creator=user, client=client) + if not validate_command(form, command, submission, 'confirmed'): + raise BadRequest(response_data) + + try: + save(command, submission_id=submission_id) + except Exception as e: + alerts.flash_failure("Whoops!") + raise InternalServerError(response_data) from e + redirect = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': redirect} + response_data.update({'form': form}) + raise BadRequest(response_data) + + +def cancel_request(method: str, params: MultiDict, session: Session, + submission_id: int, request_id: str, + **kwargs) -> Response: + submission, submission_events = load_submission(submission_id) + + # if request_type == WithdrawalRequest.NAME.lower(): + # request_klass = WithdrawalRequest + # elif request_type == CrossListClassificationRequest.NAME.lower(): + # request_klass = CrossListClassificationRequest + if request_id in submission.user_requests: + user_request = submission.user_requests[request_id] + else: + raise NotFound('No such request') + + # # Get the most recent user request of this type. + # this_request: Optional[UserRequest] = None + # for user_request in ui-app.active_user_requests[::-1]: + # if isinstance(user_request, request_klass): + # this_request = user_request + # break + # if this_request is None: + # raise NotFound('No such request') + + if not user_request.is_pending(): + raise BadRequest(f'Request is already {user_request.status}') + + response_data = { + 'ui-app': submission, + 'submission_id': submission.submission_id, + 'request_id': user_request.request_id, + 'user_request': user_request, + } + + if method == 'GET': + form = CancelRequestForm() + response_data.update({'form': form}) + return response_data, status.OK, {} + elif method == 'POST': + form = CancelRequestForm(params) + response_data.update({'form': form}) + if form.validate() and form.confirmed.data: + user, client = user_and_client_from_session(session) + command = CancelRequest(request_id=request_id, creator=user, + client=client) + if not validate_command(form, command, submission, 'confirmed'): + raise BadRequest(response_data) + + try: + save(command, submission_id=submission_id) + except Exception as e: + alerts.flash_failure("Whoops!" + str(e)) + raise InternalServerError(response_data) from e + redirect = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': redirect} + response_data.update({'form': form}) + raise BadRequest(response_data) diff --git a/submit/controllers/ui/jref.py b/submit/controllers/ui/jref.py new file mode 100644 index 0000000..77419df --- /dev/null +++ b/submit/controllers/ui/jref.py @@ -0,0 +1,150 @@ +"""Controller for JREF submissions.""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, List + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv_auth.domain import Session +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms.fields import StringField, BooleanField +from wtforms.validators import optional + +from arxiv.submission import save, Event, User, Client, Submission +from arxiv.submission.domain.event import SetDOI, SetJournalReference, \ + SetReportNumber +from arxiv.submission.exceptions import SaveError +from .util import user_and_client_from_session, FieldMixin, validate_command +from ...util import load_submission + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +class JREFForm(csrf.CSRFForm, FieldMixin): + """Set DOI and/or journal reference on a announced ui-app.""" + + doi = StringField('DOI', validators=[optional()], + description=("Full DOI of the version of record. For" + " example:" + " 10.1016/S0550-3213(01)00405-9" + )) + journal_ref = StringField('Journal reference', validators=[optional()], + description=( + "For example: Nucl.Phys.Proc.Suppl. 109" + " (2002) 3-9. See" + " " + "the arXiv help pages for details.")) + report_num = StringField('Report number', validators=[optional()], + description=( + "For example: SU-4240-720." + " Multiple report numbers should be separated" + " with a semi-colon and a space, for example:" + " SU-4240-720; LAUR-01-2140." + " See " + "the arXiv help pages for details.")) + confirmed = BooleanField('Confirmed', + false_values=('false', False, 0, '0', '')) + + +def jref(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Set journal reference metadata on a announced ui-app.""" + creator, client = user_and_client_from_session(session) + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + + # Will raise NotFound if there is no such ui-app. + submission, submission_events = load_submission(submission_id) + + # The ui-app must be announced for this to be a real JREF ui-app. + if not submission.is_announced: + alerts.flash_failure(Markup("Submission must first be announced. See " + "" + "the arXiv help pages for details.")) + status_url = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': status_url} + + # The form should be prepopulated based on the current state of the + # ui-app. + if method == 'GET': + params = MultiDict({ + 'doi': submission.metadata.doi, + 'journal_ref': submission.metadata.journal_ref, + 'report_num': submission.metadata.report_num + }) + + params.setdefault("confirmed", False) + form = JREFForm(params) + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'form': form, + } + + if method == 'POST': + # We require the user to confirm that they wish to proceed. We show + # them a preview of what their paper's abs page will look like after + # the proposed change. They can either make further changes, or + # confirm and submit. + if not form.validate(): + logger.debug('Invalid form data; return bad request') + raise BadRequest(response_data) + + if not form.confirmed.data: + response_data['require_confirmation'] = True + logger.debug('Not confirmed') + return response_data, status.OK, {} + + commands, valid = _generate_commands(form, submission, creator, client) + + if commands: # Metadata has changed; we have things to do. + if not all(valid): + raise BadRequest(response_data) + + response_data['require_confirmation'] = True + logger.debug('Form is valid, with data: %s', str(form.data)) + try: + # Save the events created during form validation. + submission, _ = save(*commands, submission_id=submission_id) + except SaveError as e: + logger.error('Could not save metadata event') + raise InternalServerError(response_data) from e + response_data['ui-app'] = submission + + # Success! Send user back to the ui-app page. + alerts.flash_success("Journal reference updated") + status_url = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': status_url} + logger.debug('Nothing to do, return 200') + return response_data, status.OK, {} + + +def _generate_commands(form: JREFForm, submission: Submission, creator: User, + client: Client) -> Tuple[List[Event], List[bool]]: + commands: List[Event] = [] + valid: List[bool] = [] + + if form.report_num.data and submission.metadata \ + and form.report_num.data != submission.metadata.report_num: + command = SetReportNumber(report_num=form.report_num.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'report_num')) + commands.append(command) + + if form.journal_ref.data and submission.metadata \ + and form.journal_ref.data != submission.metadata.journal_ref: + command = SetJournalReference(journal_ref=form.journal_ref.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, + 'journal_ref')) + commands.append(command) + + if form.doi.data and submission.metadata \ + and form.doi.data != submission.metadata.doi: + command = SetDOI(doi=form.doi.data, creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'doi')) + commands.append(command) + return commands, valid diff --git a/src/arxiv/submission/tests/annotations/__init__.py b/submit/controllers/ui/new/__init__.py similarity index 100% rename from src/arxiv/submission/tests/annotations/__init__.py rename to submit/controllers/ui/new/__init__.py diff --git a/submit/controllers/ui/new/authorship.py b/submit/controllers/ui/new/authorship.py new file mode 100644 index 0000000..e524b22 --- /dev/null +++ b/submit/controllers/ui/new/authorship.py @@ -0,0 +1,98 @@ +""" +Controller for authorship action. + +Creates an event of type `core.events.event.ConfirmAuthorship` +""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional + +from flask import url_for +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import BooleanField, RadioField +from wtforms.validators import InputRequired, ValidationError, optional + +from arxiv.base import logging +from arxiv.forms import csrf +from arxiv_auth.domain import Session +from arxiv.submission import save +from arxiv.submission.domain import Submission +from arxiv.submission.domain.event import ConfirmAuthorship +from arxiv.submission.exceptions import InvalidEvent, SaveError + +from submit.util import load_submission +from submit.controllers.ui.util import user_and_client_from_session, validate_command + +# from arxiv-ui-app-core.events.event import ConfirmContactInformation +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +def authorship(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Handle the authorship assertion view.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + + # The form should be prepopulated based on the current state of the + # ui-app. + if method == 'GET': + # Update form data based on the current state of the ui-app. + if submission.submitter_is_author is not None: + if submission.submitter_is_author: + params['authorship'] = AuthorshipForm.YES + else: + params['authorship'] = AuthorshipForm.NO + if submission.submitter_is_author is False: + params['proxy'] = True + + form = AuthorshipForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission, + 'submitter': submitter, + 'client': client, + } + + if method == 'POST' and form.validate(): + value = (form.authorship.data == form.YES) + # No need to do this more than once. + if submission.submitter_is_author != value: + command = ConfirmAuthorship(creator=submitter, client=client, + submitter_is_author=value) + if validate_command(form, command, submission, 'authorship'): + try: + submission, _ = save(command, submission_id=submission_id) + response_data['ui-app'] = submission + return response_data, status.SEE_OTHER, {} + except SaveError as e: + raise InternalServerError(response_data) from e + return ready_for_next((response_data, status.OK, {})) + + return response_data, status.OK, {} + + +class AuthorshipForm(csrf.CSRFForm): + """Generate form with radio button to confirm authorship information.""" + + YES = 'y' + NO = 'n' + + authorship = RadioField(choices=[(YES, 'I am an author of this paper'), + (NO, 'I am not an author of this paper')], + validators=[InputRequired('Please choose one')]) + proxy = BooleanField('By checking this box, I certify that I have ' + 'received authorization from arXiv to submit papers ' + 'on behalf of the author(s).', + validators=[optional()]) + + def validate_authorship(self, field: RadioField) -> None: + """Require proxy field if submitter is not author.""" + if field.data == self.NO and not self.data.get('proxy'): + raise ValidationError('You must get prior approval to submit ' + 'on behalf of authors') diff --git a/submit/controllers/ui/new/classification.py b/submit/controllers/ui/new/classification.py new file mode 100644 index 0000000..be4a357 --- /dev/null +++ b/submit/controllers/ui/new/classification.py @@ -0,0 +1,212 @@ +""" +Controller for classification actions. + +Creates an event of type `core.events.event.SetPrimaryClassification` +Creates an event of type `core.events.event.AddSecondaryClassification` +""" +from typing import Tuple, Dict, Any, List, Optional +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from flask import url_for, Markup +from wtforms import SelectField, widgets, HiddenField, validators + +from http import HTTPStatus as status +from arxiv import taxonomy +from arxiv.forms import csrf +from arxiv.base import logging, alerts +from arxiv.submission.domain import Submission +from arxiv.submission import save +from arxiv.submission.exceptions import InvalidEvent, SaveError +from arxiv_auth.domain import Session +from arxiv.submission.domain.event import RemoveSecondaryClassification, \ + AddSecondaryClassification, SetPrimaryClassification + +from submit.controllers.ui.util import validate_command, OptGroupSelectField, \ + user_and_client_from_session +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +class ClassificationForm(csrf.CSRFForm): + """Form for classification selection.""" + + CATEGORIES = [ + (archive['name'], [ + (category_id, f"{category['name']} ({category_id})") + for category_id, category in taxonomy.CATEGORIES_ACTIVE.items() + if category['in_archive'] == archive_id + ]) + for archive_id, archive in taxonomy.ARCHIVES_ACTIVE.items() + ] + """Categories grouped by archive.""" + + ADD = 'add' + REMOVE = 'remove' + OPERATIONS = [ + (ADD, 'Add'), + (REMOVE, 'Remove') + ] + operation = HiddenField(default=ADD, validators=[validators.optional()]) + category = OptGroupSelectField('Category', choices=CATEGORIES, default='') + + def filter_choices(self, submission: Submission, session: Session) -> None: + """Remove redundant choices, and limit to endorsed categories.""" + selected = self.category.data + primary = submission.primary_classification + + choices = [ + (archive, [ + (category, display) for category, display in archive_choices + if session.authorizations.endorsed_for(category) + and (((primary is None or category != primary.category) + and category not in submission.secondary_categories) + or category == selected) + ]) + for archive, archive_choices in self.category.choices + ] + self.category.choices = [ + (archive, _choices) for archive, _choices in choices + if len(_choices) > 0 + ] + + @classmethod + def formset(cls, submission: Submission) \ + -> Dict[str, 'ClassificationForm']: + """Generate a set of forms used to remove cross-list categories.""" + formset = {} + if hasattr(submission, 'secondary_classification') and \ + submission.secondary_classification: + for ix, secondary in enumerate(submission.secondary_classification): + this_category = str(secondary.category) + subform = cls(operation=cls.REMOVE, category=this_category) + subform.category.widget = widgets.HiddenInput() + subform.category.id = f"{ix}_category" + subform.operation.id = f"{ix}_operation" + subform.csrf_token.id = f"{ix}_csrf_token" + formset[secondary.category] = subform + return formset + + +class PrimaryClassificationForm(ClassificationForm): + """Form for setting the primary classification.""" + + def validate_operation(self, field) -> None: + """Make sure the client isn't monkeying with the operation.""" + if field.data != self.ADD: + raise validators.ValidationError('Invalid operation') + + +def classification(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Handle primary classification requests for a new ui-app.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + + if method == 'GET': + # Prepopulate the form based on the state of the ui-app. + if submission.primary_classification \ + and submission.primary_classification.category: + params['category'] = submission.primary_classification.category + + # Use the user's default category as the default for the form. + params.setdefault('category', session.user.profile.default_category) + + params['operation'] = PrimaryClassificationForm.ADD + + form = PrimaryClassificationForm(params) + form.filter_choices(submission, session) + + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'submitter': submitter, + 'client': client, + 'form': form + } + + command = SetPrimaryClassification(category=form.category.data, + creator=submitter, client=client) + if method == 'POST' and form.validate()\ + and validate_command(form, command, submission, 'category'): + try: + submission, _ = save(command, submission_id=submission_id) + response_data['ui-app'] = submission + except SaveError as ex: + raise InternalServerError(response_data) from ex + return ready_for_next((response_data, status.OK, {})) + + return response_data, status.OK, {} + + +def cross_list(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Handle secondary classification requests for a new submision.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + + form = ClassificationForm(params) + form.operation._value = lambda: form.operation.data + form.filter_choices(submission, session) + + # Create a formset to render removal option. + # + # We need forms for existing secondaries, to generate removal requests. + # When the forms in the formset are submitted, they are handled as the + # primary form in the POST request to this controller. + formset = ClassificationForm.formset(submission) + _primary_category = submission.primary_classification.category + _primary = taxonomy.CATEGORIES[_primary_category] + + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'submitter': submitter, + 'client': client, + 'form': form, + 'formset': formset, + 'primary': { + 'id': submission.primary_classification.category, + 'name': _primary['name'] + }, + } + + # Ensure the user is not attempting to move to a different step. + # Since the interface provides an "add" button to add cross-list + # categories, we only want to handle the form data if the user is not + # attempting to move to a different step. + + if form.operation.data == form.REMOVE: + command_type = RemoveSecondaryClassification + else: + command_type = AddSecondaryClassification + command = command_type(category=form.category.data, + creator=submitter, client=client) + if method == 'POST' and form.validate() \ + and validate_command(form, command, submission, 'category'): + try: + submission, _ = save(command, submission_id=submission_id) + response_data['ui-app'] = submission + + # Re-build the formset to reflect changes that we just made, and + # generate a fresh form for adding another secondary. The POSTed + # data should now be reflected in the formset. + response_data['formset'] = ClassificationForm.formset(submission) + form = ClassificationForm() + form.operation._value = lambda: form.operation.data + form.filter_choices(submission, session) + response_data['form'] = form + + # do not go to next yet, re-show cross form + return stay_on_this_stage((response_data, status.OK, {})) + except SaveError as ex: + raise InternalServerError(response_data) from ex + + + if len(submission.secondary_categories) > 3: + alerts.flash_warning(Markup( + 'Adding more than three cross-list classifications will' + ' result in a delay in the acceptance of your ui-app.' + )) + return response_data, status.OK, {} diff --git a/submit/controllers/ui/new/create.py b/submit/controllers/ui/new/create.py new file mode 100644 index 0000000..d38cac3 --- /dev/null +++ b/submit/controllers/ui/new/create.py @@ -0,0 +1,105 @@ +"""Controller for creating a new ui-app.""" + +from http import HTTPStatus as status +from typing import Optional, Tuple, Dict, Any + +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest, \ + MethodNotAllowed +from flask import url_for +from retry import retry + +from arxiv.forms import csrf +from arxiv.base import logging +from arxiv_auth.domain import Session, User + +from arxiv.submission import save +from arxiv.submission.domain import Submission +from arxiv.submission.domain.event import CreateSubmission, \ + CreateSubmissionVersion +from arxiv.submission.exceptions import InvalidEvent, SaveError +from arxiv.submission.core import load_submissions_for_user + +from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage, advance_to_current + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + + +class CreateSubmissionForm(csrf.CSRFForm): + """Submission creation form.""" + + +def create(method: str, params: MultiDict, session: Session, *args, + **kwargs) -> Response: + """Create a new ui-app, and redirect to workflow.""" + submitter, client = user_and_client_from_session(session) + response_data = {} + if method == 'GET': # Display a splash page. + response_data['user_submissions'] \ + = _load_submissions_for_user(session.user.user_id) + params = MultiDict() + + # We're using a form here for CSRF protection. + form = CreateSubmissionForm(params) + response_data['form'] = form + + command = CreateSubmission(creator=submitter, client=client) + if method == 'POST' and form.validate() and validate_command(form, command): + try: + submission, _ = save(command) + except SaveError as e: + logger.error('Could not save command: %s', e) + raise InternalServerError(response_data) from e + + # TODO Do we need a better way to enter a workflow? + # Maybe a controller that is defined as the entrypoint? + loc = url_for('ui.verify_user', submission_id=submission.submission_id) + return {}, status.SEE_OTHER, {'Location': loc} + + return advance_to_current((response_data, status.OK, {})) + + +def replace(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Create a new version, and redirect to workflow.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'submitter': submitter, + 'client': client, + } + + if method == 'GET': # Display a splash page. + response_data['form'] = CreateSubmissionForm() + + if method == 'POST': + # We're using a form here for CSRF protection. + form = CreateSubmissionForm(params) + response_data['form'] = form + if not form.validate(): + raise BadRequest('Invalid request') + + submitter, client = user_and_client_from_session(session) + submission, _ = load_submission(submission_id) + command = CreateSubmissionVersion(creator=submitter, client=client) + if not validate_command(form, command, submission): + raise BadRequest({}) + + try: + submission, _ = save(command, submission_id=submission_id) + except SaveError as e: + logger.error('Could not save command: %s', e) + raise InternalServerError({}) from e + + loc = url_for('ui.verify_user', submission_id=submission.submission_id) + return {}, status.SEE_OTHER, {'Location': loc} + return response_data, status.OK, {} + + +@retry(tries=3, delay=0.1, backoff=2) +def _load_submissions_for_user(user_id: str): + return load_submissions_for_user(user_id) diff --git a/submit/controllers/ui/new/final.py b/submit/controllers/ui/new/final.py new file mode 100644 index 0000000..92f8b39 --- /dev/null +++ b/submit/controllers/ui/new/final.py @@ -0,0 +1,83 @@ +""" +Provides the final preview and confirmation step. +""" + +from typing import Tuple, Dict, Any + +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from flask import url_for +from wtforms import BooleanField +from wtforms.validators import InputRequired + +from http import HTTPStatus as status +from arxiv.forms import csrf +from arxiv.base import logging +from arxiv_auth.domain import Session +from arxiv.submission import save +from arxiv.submission.domain.event import FinalizeSubmission +from arxiv.submission.exceptions import SaveError +from submit.util import load_submission +from submit.controllers.ui.util import validate_command, user_and_client_from_session +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +def finalize(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + submitter, client = user_and_client_from_session(session) + + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + submission, submission_events = load_submission(submission_id) + + form = FinalizationForm(params) + + # The abs preview macro expects a specific struct for ui-app history. + submission_history = [{'submitted_date': s.created, 'version': s.version} + for s in submission.versions] + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission, + 'submission_history': submission_history + } + + command = FinalizeSubmission(creator=submitter) + proofread_confirmed = form.proceed.data + if method == 'POST' and form.validate() \ + and proofread_confirmed \ + and validate_command(form, command, submission): + try: + submission, stack = save( # pylint: disable=W0612 + command, submission_id=submission_id) + except SaveError as e: + logger.error('Could not save primary event') + raise InternalServerError(response_data) from e + return ready_for_next((response_data, status.OK, {})) + else: + return stay_on_this_stage((response_data, status.OK, {})) + + return response_data, status.OK, {} + + +class FinalizationForm(csrf.CSRFForm): + """Make sure the user is really really really ready to submit.""" + + proceed = BooleanField( + 'By checking this box, I confirm that I have reviewed my ui-app as' + ' it will appear on arXiv.', + [InputRequired('Please confirm that the ui-app is ready')] + ) + + +def confirm(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + submission, submission_events = load_submission(submission_id) + response_data = { + 'submission_id': submission_id, + 'ui-app': submission + } + return response_data, status.OK, {} diff --git a/submit/controllers/ui/new/license.py b/submit/controllers/ui/new/license.py new file mode 100644 index 0000000..cafa92e --- /dev/null +++ b/submit/controllers/ui/new/license.py @@ -0,0 +1,76 @@ +""" +Controller for license action. + +Creates an event of type `core.events.event.SetLicense` +""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any + +from flask import url_for +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms.fields import RadioField +from wtforms.validators import InputRequired + +from arxiv.forms import csrf +from arxiv.base import logging +from arxiv.license import LICENSES +from arxiv_auth.domain import Session +from arxiv.submission import save, InvalidEvent, SaveError +from arxiv.submission.domain.event import SetLicense +from submit.util import load_submission +from submit.controllers.ui.util import validate_command, user_and_client_from_session +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +def license(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Convert license form data into a `SetLicense` event.""" + submitter, client = user_and_client_from_session(session) + + submission, submission_events = load_submission(submission_id) + + if method == 'GET' and submission.license: + # The form should be prepopulated based on the current state of the + # ui-app. + params['license'] = submission.license.uri + + form = LicenseForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission + } + + if method == 'POST' and form.validate(): + license_uri = form.license.data + if submission.license and submission.license.uri == license_uri: + return ready_for_next((response_data, status.OK, {})) + if not submission.license \ + or submission.license.uri != license_uri: + command = SetLicense(creator=submitter, client=client, + license_uri=license_uri) + if validate_command(form, command, submission, 'license'): + try: + submission, _ = save(command, submission_id=submission_id) + return ready_for_next((response_data, status.OK, {})) + except SaveError as e: + raise InternalServerError(response_data) from e + + return stay_on_this_stage((response_data, status.OK, {})) + + +class LicenseForm(csrf.CSRFForm): + """Generate form to select license.""" + + LICENSE_CHOICES = [(uri, data['label']) for uri, data in LICENSES.items() + if data['is_current']] + + license = RadioField(u'Select a license', choices=LICENSE_CHOICES, + validators=[InputRequired('Please select a license')]) diff --git a/submit/controllers/ui/new/metadata.py b/submit/controllers/ui/new/metadata.py new file mode 100644 index 0000000..8616d75 --- /dev/null +++ b/submit/controllers/ui/new/metadata.py @@ -0,0 +1,247 @@ +"""Provides a controller for updating metadata on a ui-app.""" + +from typing import Tuple, Dict, Any, List + +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms.fields import StringField, TextAreaField, Field +from wtforms import validators + +from http import HTTPStatus as status +from arxiv.forms import csrf +from arxiv.base import logging +from arxiv_auth.domain import Session +from arxiv.submission import save, SaveError, Submission, User, Client, Event +from arxiv.submission.domain.event import SetTitle, SetAuthors, SetAbstract, \ + SetACMClassification, SetMSCClassification, SetComments, SetReportNumber, \ + SetJournalReference, SetDOI + +from submit.util import load_submission +from submit.controllers.ui.util import validate_command, FieldMixin, user_and_client_from_session +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +class CoreMetadataForm(csrf.CSRFForm, FieldMixin): + """Handles core metadata fields on a ui-app.""" + + title = StringField('*Title', validators=[validators.DataRequired()]) + authors_display = TextAreaField( + '*Authors', + validators=[validators.DataRequired()], + description=( + "use GivenName(s) FamilyName(s) or I. " + "FamilyName; separate individual authors with " + "a comma or 'and'." + ) + ) + abstract = TextAreaField('*Abstract', + validators=[validators.DataRequired()], + description='Limit of 1920 characters') + comments = StringField('Comments', + default='', + validators=[validators.optional()], + description=( + "Supplemental information such as number of pages " + "or figures, conference information." + )) + + +class OptionalMetadataForm(csrf.CSRFForm, FieldMixin): + """Handles optional metadata fields on a ui-app.""" + + doi = StringField('DOI', + validators=[validators.optional()], + description="Full DOI of the version of record.") + journal_ref = StringField('Journal reference', + validators=[validators.optional()], + description=( + "See " + "the arXiv help pages for details." + )) + report_num = StringField('Report number', + validators=[validators.optional()], + description=( + "See " + "the arXiv help pages for details." + )) + acm_class = StringField('ACM classification', + validators=[validators.optional()], + description="example: F.2.2; I.2.7") + + msc_class = StringField('MSC classification', + validators=[validators.optional()], + description=("example: 14J60 (Primary), 14F05, " + "14J26 (Secondary)")) + + +def _data_from_submission(params: MultiDict, submission: Submission, + form_class: type) -> MultiDict: + if not submission.metadata: + return params + for field in form_class.fields(): + params[field] = getattr(submission.metadata, field, '') + return params + + +def metadata(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Update metadata on the ui-app.""" + submitter, client = user_and_client_from_session(session) + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + + # Will raise NotFound if there is no such ui-app. + submission, submission_events = load_submission(submission_id) + # The form should be prepopulated based on the current state of the + # ui-app. + if method == 'GET': + params = _data_from_submission(params, submission, CoreMetadataForm) + + form = CoreMetadataForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission + } + + if method == 'POST' and form.validate(): + commands, valid = _commands(form, submission, submitter, client) + # We only want to apply an UpdateMetadata if the metadata has + # actually changed. + if commands and all(valid): # Metadata has changed and is valid + try: + # Save the events created during form validation. + submission, _ = save(*commands, submission_id=submission_id) + response_data['ui-app'] = submission + return ready_for_next((response_data, status.OK, {})) + except SaveError as e: + raise InternalServerError(response_data) from e + else: + return ready_for_next((response_data, status.OK, {})) + else: + return stay_on_this_stage((response_data, status.OK, {})) + + return response_data, status.OK, {} + + +def optional(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Update optional metadata on the ui-app.""" + submitter, client = user_and_client_from_session(session) + + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + + # Will raise NotFound if there is no such ui-app. + submission, submission_events = load_submission(submission_id) + # The form should be prepopulated based on the current state of the + # ui-app. + if method == 'GET': + params = _data_from_submission(params, submission, + OptionalMetadataForm) + + form = OptionalMetadataForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission + } + + if method == 'POST' and form.validate(): + logger.debug('Form is valid, with data: %s', str(form.data)) + + commands, valid = _opt_commands(form, submission, submitter, client) + # We only want to apply updates if the metadata has actually changed. + if not commands: + return ready_for_next((response_data, status.OK, {})) + if all(valid): # Metadata has changed and is all valid + try: + submission, _ = save(*commands, submission_id=submission_id) + response_data['ui-app'] = submission + return ready_for_next((response_data, status.OK, {})) + except SaveError as e: + raise InternalServerError(response_data) from e + + return stay_on_this_stage((response_data, status.OK, {})) + + +def _commands(form: CoreMetadataForm, submission: Submission, + creator: User, client: Client) -> Tuple[List[Event], List[bool]]: + commands: List[Event] = [] + valid: List[bool] = [] + + if form.title.data and submission.metadata \ + and form.title.data != submission.metadata.title: + command = SetTitle(title=form.title.data, creator=creator, + client=client) + valid.append(validate_command(form, command, submission, 'title')) + commands.append(command) + + if form.abstract.data and submission.metadata \ + and form.abstract.data != submission.metadata.abstract: + command = SetAbstract(abstract=form.abstract.data, creator=creator, + client=client) + valid.append(validate_command(form, command, submission, 'abstract')) + commands.append(command) + + if form.comments.data and submission.metadata \ + and form.comments.data != submission.metadata.comments: + command = SetComments(comments=form.comments.data, creator=creator, + client=client) + valid.append(validate_command(form, command, submission, 'comments')) + commands.append(command) + + value = form.authors_display.data + if value and submission.metadata \ + and value != submission.metadata.authors_display: + command = SetAuthors(authors_display=form.authors_display.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, + 'authors_display')) + commands.append(command) + return commands, valid + + +def _opt_commands(form: OptionalMetadataForm, submission: Submission, + creator: User, client: Client) \ + -> Tuple[List[Event], List[bool]]: + + commands: List[Event] = [] + valid: List[bool] = [] + + if form.msc_class.data and submission.metadata \ + and form.msc_class.data != submission.metadata.msc_class: + command = SetMSCClassification(msc_class=form.msc_class.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'msc_class')) + commands.append(command) + + if form.acm_class.data and submission.metadata \ + and form.acm_class.data != submission.metadata.acm_class: + command = SetACMClassification(acm_class=form.acm_class.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'acm_class')) + commands.append(command) + + if form.report_num.data and submission.metadata \ + and form.report_num.data != submission.metadata.report_num: + command = SetReportNumber(report_num=form.report_num.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'report_num')) + commands.append(command) + + if form.journal_ref.data and submission.metadata \ + and form.journal_ref.data != submission.metadata.journal_ref: + command = SetJournalReference(journal_ref=form.journal_ref.data, + creator=creator, client=client) + valid.append(validate_command(form, command, submission, + 'journal_ref')) + commands.append(command) + + if form.doi.data and submission.metadata \ + and form.doi.data != submission.metadata.doi: + command = SetDOI(doi=form.doi.data, creator=creator, client=client) + valid.append(validate_command(form, command, submission, 'doi')) + commands.append(command) + return commands, valid diff --git a/submit/controllers/ui/new/policy.py b/submit/controllers/ui/new/policy.py new file mode 100644 index 0000000..b51e746 --- /dev/null +++ b/submit/controllers/ui/new/policy.py @@ -0,0 +1,68 @@ +""" +Controller for policy action. + +Creates an event of type `core.events.event.ConfirmPolicy` +""" +from http import HTTPStatus as status +from typing import Tuple, Dict, Any + +from flask import url_for +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms import BooleanField +from wtforms.validators import InputRequired + +from arxiv.forms import csrf +from arxiv.base import logging +from arxiv_auth.domain import Session +from arxiv.submission import save, SaveError +from arxiv.submission.domain.event import ConfirmPolicy + +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage +from submit.controllers.ui.util import validate_command, \ + user_and_client_from_session + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +def policy(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Convert policy form data into an `ConfirmPolicy` event.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + + if method == 'GET' and submission.submitter_accepts_policy: + params['policy'] = 'true' + + form = PolicyForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission + } + + if method == 'POST' and form.validate(): + accept_policy = form.policy.data + if accept_policy and submission.submitter_accepts_policy: + return ready_for_next((response_data, status.OK, {})) + if accept_policy and not submission.submitter_accepts_policy: + command = ConfirmPolicy(creator=submitter, client=client) + if validate_command(form, command, submission, 'policy'): + try: + submission, _ = save(command, submission_id=submission_id) + response_data['ui-app'] = submission + return ready_for_next((response_data, status.OK, {})) + except SaveError as e: + raise InternalServerError(response_data) from e + + return stay_on_this_stage((response_data, status.OK, {})) + + +class PolicyForm(csrf.CSRFForm): + """Generate form with checkbox to confirm policy.""" + + policy = BooleanField( + 'By checking this box, I agree to the policies listed on this page.', + [InputRequired('Please check the box to agree to the policies')] + ) diff --git a/submit/controllers/ui/new/process.py b/submit/controllers/ui/new/process.py new file mode 100644 index 0000000..0344dbd --- /dev/null +++ b/submit/controllers/ui/new/process.py @@ -0,0 +1,257 @@ +""" +Controllers for process-related requests. + +The controllers in this module leverage +:mod:`arxiv.ui-app.core.process.process_source`, which provides an +high-level API for orchestrating source processing for all supported source +types. +""" + +import io +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv.integration.api import exceptions +from arxiv.submission import save, SaveError +from arxiv.submission.domain.event import ConfirmSourceProcessed +from arxiv.submission.process import process_source +from arxiv.submission.services import PreviewService, Compiler +from arxiv_auth.domain import Session +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, MethodNotAllowed +from wtforms import SelectField +from .reasons import TEX_PRODUCED_MARKUP, DOCKER_ERROR_MARKUOP, SUCCESS_MARKUP +from submit.controllers.ui.util import user_and_client_from_session +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage +from submit.util import load_submission + +logger = logging.getLogger(__name__) + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +SUPPORT = Markup( + 'If you continue to experience problems, please contact' + ' arXiv support.' +) + + +def file_process(method: str, params: MultiDict, session: Session, + submission_id: int, token: str, **kwargs: Any) -> Response: + """ + Process the file compilation project. + + Parameters + ---------- + method : str + ``GET`` or ``POST`` + session : :class:`Session` + The authenticated session for the request. + submission_id : int + The identifier of the ui-app for which the upload is being made. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``200`` or ``303``, unless something + goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response, if + applicable. + + """ + if method == "GET": + return compile_status(params, session, submission_id, token) + elif method == "POST": + if params.get('action') in ['previous', 'next', 'save_exit']: + return _check_status(params, session, submission_id, token) + # User is not actually trying to process anything; let flow control + # in the routes handle the response. + # TODO there is a chance this will allow the user to go to next stage without processing + # return ready_for_next({}, status.SEE_OTHER, {}) + else: + return start_compilation(params, session, submission_id, token) + raise MethodNotAllowed('Unsupported request') + + +def _check_status(params: MultiDict, session: Session, submission_id: int, + token: str, **kwargs: Any) -> None: + """ + Check for cases in which the preview already exists. + + This will catch cases in which the ui-app is PDF-only, or otherwise + requires no further compilation. + """ + submitter, client = user_and_client_from_session(session) + submission, _ = load_submission(submission_id) + + if not submission.is_source_processed: + form = CompilationForm(params) # Providing CSRF protection. + if not form.validate(): + return stay_on_this_stage(({'form': form}, status.OK, {})) + + command = ConfirmSourceProcessed(creator=submitter, client=client) + try: + submission, _ = save(command, submission_id=submission_id) + return ready_for_next(({}, status.OK, {})) + except SaveError as e: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please' + f' try again. {SUPPORT}' + )) + logger.error('Error while saving command %s: %s', + command.event_id, e) + raise InternalServerError('Could not save changes') from e + else: + return ready_for_next(({}, status.OK, {})) + + +def compile_status(params: MultiDict, session: Session, submission_id: int, + token: str, **kwargs: Any) -> Response: + """ + Returns the status of a compilation. + + Parameters + ---------- + session : :class:`Session` + The authenticated session for the request. + submission_id : int + The identifier of the ui-app for which the upload is being made. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``200`` or ``303``, unless something + goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response, if + applicable. + + """ + submitter, client = user_and_client_from_session(session) + submission, _ = load_submission(submission_id) + form = CompilationForm() + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'form': form, + 'status': None, + } + # Determine whether the current state of the uploaded source content has + # been compiled. + result: Optional[process_source.CheckResult] = None + try: + result = process_source.check(submission, submitter, client, token) + except process_source.NoProcessToCheck as e: + pass + except process_source.FailedToCheckStatus as e: + logger.error('Failed to check status: %s', e) + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {SUPPORT}' + )) + if result is not None: + response_data['status'] = result.status + response_data.update(**result.extra) + return stay_on_this_stage((response_data, status.OK, {})) + + +def start_compilation(params: MultiDict, session: Session, submission_id: int, + token: str, **kwargs: Any) -> Response: + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + form = CompilationForm(params) + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'form': form, + 'status': None, + } + + if not form.validate(): + return stay_on_this_stage((response_data,status.OK,{})) + + try: + result = process_source.start(submission, submitter, client, token) + except process_source.FailedToStart as e: + alerts.flash_failure(f"We couldn't process your ui-app. {SUPPORT}", + title="Processing failed") + logger.error('Error while requesting compilation for %s: %s', + submission_id, e) + raise InternalServerError(response_data) from e + + response_data['status'] = result.status + response_data.update(**result.extra) + + if result.status == process_source.FAILED: + if 'reason' in result.extra and "produced from TeX source" in result.extra['reason']: + alerts.flash_failure(TEX_PRODUCED_MARKUP) + elif 'reason' in result.extra and 'docker' in result.extra['reason']: + alerts.flash_failure(DOCKER_ERROR_MARKUOP) + else: + alerts.flash_failure(f"Processing failed") + else: + alerts.flash_success(SUCCESS_MARKUP, title="Processing started" + ) + + return stay_on_this_stage((response_data, status.OK, {})) + + +def file_preview(params, session: Session, submission_id: int, token: str, + **kwargs: Any) -> Tuple[io.BytesIO, int, Dict[str, str]]: + """Serve the PDF preview for a ui-app.""" + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + p = PreviewService.current_session() + stream, pdf_checksum = p.get(submission.source_content.identifier, + submission.source_content.checksum, + token) + headers = {'Content-Type': 'application/pdf', 'ETag': pdf_checksum} + return stream, status.OK, headers + + +def compilation_log(params, session: Session, submission_id: int, token: str, + **kwargs: Any) -> Response: + submitter, client = user_and_client_from_session(session) + submission, submission_events = load_submission(submission_id) + checksum = params.get('checksum', submission.source_content.checksum) + try: + log = Compiler.get_log(submission.source_content.identifier, checksum, + token) + headers = {'Content-Type': log.content_type, 'ETag': checksum} + return log.stream, status.OK, headers + except exceptions.NotFound: + raise NotFound("No log output produced") + + +def compile(params: MultiDict, session: Session, submission_id: int, + token: str, **kwargs) -> Response: + redirect = url_for('ui.file_process', submission_id=submission_id) + return {}, status.SEE_OTHER, {'Location': redirect} + + +class CompilationForm(csrf.CSRFForm): + """Generate form to process compilation.""" + + PDFLATEX = 'pdflatex' + COMPILERS = [ + (PDFLATEX, 'PDFLaTeX') + ] + + compiler = SelectField('Compiler', choices=COMPILERS, + default=PDFLATEX) diff --git a/submit/controllers/ui/new/reasons.py b/submit/controllers/ui/new/reasons.py new file mode 100644 index 0000000..37f24f7 --- /dev/null +++ b/submit/controllers/ui/new/reasons.py @@ -0,0 +1,37 @@ +"""Descriptive user-firendly error explanations for process errors.""" + +from flask import url_for, Markup + +"""Attempt to convert short error messages into more user-friendly +warning messages. + +These messages are accompanied by user instructions (what to do) that +appear in the process.html template). + +NOTE: Move these into subdirectory at some point. Possibly as part of process + package. +""" + +SUCCESS_MARKUP = \ + Markup("We are processing your ui-app. This may take a minute or two." \ + " This page will refresh automatically every 5 seconds. You can " \ + " also refresh this page manually to check the current status. ") + +TEX_PRODUCED_MARKUP = \ + Markup("The ui-app PDF file appears to have been produced by TeX. " \ + "

This file has been rejected as part your ui-app because " \ + "it appears to be pdf generated from TeX/LaTeX source. " \ + "For the reasons outlined at in the Why TeX FAQ we insist on " \ + "ui-app of the TeX source rather than the processed " \ + "version.

Our software includes an automatic TeX " \ + "processing script that will produce PDF, PostScript and " \ + "dvi from your TeX source. If our determination that your " \ + "ui-app is TeX produced is incorrect, you should send " \ + "e-mail with your ui-app ID to " \ + 'arXiv administrators.

') + +DOCKER_ERROR_MARKUOP = \ + Markup("Our automatic TeX processing system has failed to launch. " \ + "There is a good cchance we are aware of the issue, but if the " \ + "problem persists you should send e-mail with your ui-app " \ + 'number to arXiv administrators.

') \ No newline at end of file diff --git a/submit/controllers/ui/new/tests/test_authorship.py b/submit/controllers/ui/new/tests/test_authorship.py new file mode 100644 index 0000000..fcc40c5 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_authorship.py @@ -0,0 +1,152 @@ +"""Tests for :mod:`submit.controllers.authorship`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from unittest import TestCase, mock + +from arxiv_auth import domain, auth +from pytz import timezone +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form + +import arxiv.submission as events +from arxiv.submission.domain.event import ConfirmAuthorship + +from submit.controllers.ui.new import authorship +from submit.routes.ui.flow_control import get_controllers_desire, STAGE_RESHOW + +class TestVerifyAuthorship(TestCase): + """Test behavior of :func:`.authorship` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + submitter_is_author=False) + mock_load.return_value = (before, []) + data, code, _ = authorship.authorship('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + params = MultiDict() + + with self.assertRaises(NotFound): + authorship.authorship('GET', params, self.session, submission_id) + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + submitter_is_author=False) + mock_load.return_value = (before, []) + params = MultiDict() + _, code, _ = authorship.authorship('POST', params, self.session, submission_id) + self.assertEqual(code, status.OK) + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_not_author_no_proxy(self, mock_load): + """User indicates they are not author, but also not proxy.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + submitter_is_author=False) + mock_load.return_value = (before, []) + params = MultiDict({'authorship': authorship.AuthorshipForm.NO}) + data, code, _ = authorship.authorship('POST', params, self.session, submission_id) + self.assertEqual(code, status.OK) + + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{authorship.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `authorship` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_is_author=False) + after = mock.MagicMock(submission_id=submission_id, is_finalized=False) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_url_for.return_value = 'https://foo.bar.com/yes' + + params = MultiDict({'authorship': 'y', 'action': 'next'}) + _, code, _ = authorship.authorship('POST', params, self.session, + submission_id) + self.assertEqual(code, status.SEE_OTHER, "Returns redirect") + + @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{authorship.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_fails(self, mock_load, mock_save, mock_url_for): + """Event store flakes out on saving the command.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_is_author=False) + mock_load.return_value = (before, []) + + def raise_on_verify(*ev, **kwargs): + if type(ev[0]) is ConfirmAuthorship: + raise events.SaveError('The world is ending') + submission_id = kwargs.get('submission_id', 2) + return (mock.MagicMock(submission_id=submission_id), []) + + mock_save.side_effect = raise_on_verify + params = MultiDict({'authorship': 'y', 'action': 'next'}) + + try: + authorship.authorship('POST', params, self.session, 2) + self.fail('InternalServerError not raised') + except InternalServerError as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes form") diff --git a/submit/controllers/ui/new/tests/test_classification.py b/submit/controllers/ui/new/tests/test_classification.py new file mode 100644 index 0000000..becbd52 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_classification.py @@ -0,0 +1,268 @@ +"""Tests for :mod:`submit.controllers.classification`.""" + +from unittest import TestCase, mock + +from arxiv_auth import domain, auth +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form +from http import HTTPStatus as status +import arxiv.submission as events +from submit.controllers.ui.new import classification + +from pytz import timezone +from datetime import timedelta, datetime + +class TestClassification(TestCase): + """Test behavior of :func:`.classification` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + data, code, _ = classification.classification('GET', MultiDict(), + self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + with self.assertRaises(NotFound): + classification.classification('GET', MultiDict(), self.session, + submission_id) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + + data, _, _ = classification.classification('POST', MultiDict(), self.session, + submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_with_invalid_category(self, mock_load, mock_save): + """POST request with invalid category.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + mock_save.return_value = (before, []) + + params = MultiDict({'category': 'astro-ph'}) # <- expired + + data, _, _ = classification.classification('POST', params, self.session, + submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_with_category(self, mock_load, mock_save): + """POST request with valid category.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + is_announced=False, version=1, arxiv_id=None) + mock_clsn = mock.MagicMock(category='astro-ph.CO') + after = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + primary_classification=mock_clsn, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + params = MultiDict({'category': 'astro-ph.CO'}) + data, code, _ = classification.classification('POST', params, + self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + + self.assertIsInstance(data['form'], Form, "Data includes a form") + + +class TestCrossList(TestCase): + """Test behavior of :func:`.cross_list` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + mock_clsn = mock.MagicMock(category='astro-ph.EP') + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + primary_classification=mock_clsn, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + params = MultiDict() + data, code, _ = classification.cross_list('GET', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + with self.assertRaises(NotFound): + classification.cross_list('GET', MultiDict(), self.session, + submission_id) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + mock_clsn = mock.MagicMock(category='astro-ph.EP') + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + primary_classification=mock_clsn, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + + data, _, _ = classification.cross_list('POST', MultiDict(), self.session, + submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_with_invalid_category(self, mock_load, mock_save): + """POST request with invalid category.""" + submission_id = 2 + mock_clsn = mock.MagicMock(category='astro-ph.EP') + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + primary_classification=mock_clsn, + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + mock_save.return_value = (before, []) + params = MultiDict({'category': 'astro-ph'}) # <- expired + data, _, _ = classification.classification('POST', params, self.session, + submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_with_category(self, mock_load, mock_save): + """POST request with valid category.""" + submission_id = 2 + mock_clsn = mock.MagicMock(category='astro-ph.EP') + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + primary_classification=mock_clsn, + primary_category='astro-ph.EP', + is_announced=False, version=1, arxiv_id=None) + after = mock.MagicMock(submission_id=submission_id, is_finalized=False, + primary_classification=mock_clsn, + primary_category='astro-ph.EP', + secondary_categories=[ + mock.MagicMock(category='astro-ph.CO') + ], + is_announced=False, version=1, arxiv_id=None) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + params = MultiDict({'category': 'astro-ph.CO'}) + data, code, _ = classification.cross_list('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_license.py b/submit/controllers/ui/new/tests/test_license.py new file mode 100644 index 0000000..ed4646d --- /dev/null +++ b/submit/controllers/ui/new/tests/test_license.py @@ -0,0 +1,165 @@ +"""Tests for :mod:`submit.controllers.license`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from unittest import TestCase, mock + +from pytz import timezone +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form + +import arxiv.submission as events +from arxiv.submission.domain.event import SetLicense +from arxiv_auth import auth, domain + +from submit.controllers.ui.new import license + +from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS + +class TestSetLicense(TestCase): + """Test behavior of :func:`.license` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock(submission_id=submission_id), [] + ) + rdata, code, _ = license.license('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(rdata['form'], Form, "Data includes a form") + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + with self.assertRaises(NotFound): + license.license('GET', MultiDict(), self.session, submission_id) + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock(submission_id=submission_id), [] + ) + data, _, _ = license.license('POST', MultiDict(), self.session, submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{license.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `license` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + sub = mock.MagicMock(submission_id=submission_id, is_finalized=False) + mock_load.return_value = (sub, []) + mock_save.return_value = (sub, []) + # `url_for` returns a URL (unsurprisingly). + redirect_url = 'https://foo.bar.com/yes' + mock_url_for.return_value = redirect_url + + form_data = MultiDict({ + 'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', + 'action': 'next' + }) + data, code, headers = license.license('POST', form_data, self.session, + submission_id) + self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) + + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{license.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `license` set and same license already on ui-app.""" + submission_id = 2 + arxiv_lic = 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/' + lic = mock.MagicMock(uri=arxiv_lic) + sub = mock.MagicMock(submission_id=submission_id, + license=lic, + is_finalized=False) + mock_load.return_value = (sub, []) + mock_save.return_value = (sub, []) + mock_url_for.return_value = 'https://example.com/' + + form_data = MultiDict({ + 'license': arxiv_lic, + 'action': 'next' + }) + data, code, headers = license.license('POST', form_data, self.session, + submission_id) + self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) + + @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{license.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_fails(self, mock_load, mock_save, mock_url_for): + """Event store flakes out on saving license selection.""" + submission_id = 2 + sub = mock.MagicMock(submission_id=submission_id, is_finalized=False) + mock_load.return_value = (sub, []) + + # Event store does not complain; returns object with `submission_id` + def raise_on_verify(*ev, **kwargs): + if type(ev[0]) is SetLicense: + raise events.SaveError('the sky is falling') + ident = kwargs.get('submission_id', 2) + return (mock.MagicMock(submission_id=ident), []) + + mock_save.side_effect = raise_on_verify + params = MultiDict({ + 'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', + 'action': 'next' + }) + try: + license.license('POST', params, self.session, 2) + self.fail('InternalServerError not raised') + except InternalServerError as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_metadata.py b/submit/controllers/ui/new/tests/test_metadata.py new file mode 100644 index 0000000..271fd32 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_metadata.py @@ -0,0 +1,405 @@ +"""Tests for :mod:`submit.controllers.metadata`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from unittest import TestCase, mock + +from pytz import timezone +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms import Form + +import arxiv.submission as events +from arxiv.submission.domain.event import SetTitle, SetAbstract, SetAuthors, \ + SetReportNumber, SetMSCClassification, SetACMClassification, SetDOI, \ + SetJournalReference +from arxiv_auth import auth, domain + +from submit.controllers.ui.new import metadata + + +class TestOptional(TestCase): + """Tests for :func:`.optional`.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock(submission_id=submission_id), [] + ) + data, code, headers = metadata.optional( + 'GET', MultiDict(), self.session, submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, + "Response data includes a form") + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request_with_no_data(self, mock_load): + """POST request has no form data.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock(submission_id=submission_id), [] + ) + data, code, headers = metadata.optional( + 'POST', MultiDict(), self.session, submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + + self.assertIsInstance(data['form'], Form, + "Response data includes a form") + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_error_is_raised(self, mock_load, mock_save): + """POST request results in an SaveError exception.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock() + ) + mock_load.return_value = (mock_submission, []) + + def raise_save_error(*args, **kwargs): + raise events.SaveError('nope') + + mock_save.side_effect = raise_save_error + params = MultiDict({ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 12', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J26' + }) + with self.assertRaises(InternalServerError): + metadata.optional('POST', params, self.session, submission_id) + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_required_data(self, mock_load, mock_save): + """POST request with all fields.""" + submission_id = 2 + mock_submission = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock()) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 12', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J26' + }) + data, code, headers = metadata.optional('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + event_types = [type(ev) for ev in mock_save.call_args[0]] + self.assertIn(SetDOI, event_types, "Sets ui-app DOI") + self.assertIn(SetJournalReference, event_types, + "Sets journal references") + self.assertIn(SetReportNumber, event_types, + "Sets report number") + self.assertIn(SetACMClassification, event_types, + "Sets ACM classification") + self.assertIn(SetMSCClassification, event_types, + "Sets MSC classification") + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_unchanged_data(self, mock_load, mock_save): + """POST request with valid but unchanged data.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock(**{ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 12', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J26' + }) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 12', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J26' + }) + _, code, _ = metadata.optional('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertEqual(mock_save.call_count, 0, "No events are generated") + + @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_some_changes(self, mock_load, mock_save): + """POST request with only some changed data.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock(**{ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 12', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J26' + }) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'doi': '10.0001/123456', + 'journal_ref': 'foo journal 10 2010: 12-345', + 'report_num': 'foo report 13', + 'acm_class': 'F.2.2; I.2.7', + 'msc_class': '14J27' + }) + _, code, _ = metadata.optional('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertEqual(mock_save.call_count, 1, "Events are generated") + + event_types = [type(ev) for ev in mock_save.call_args[0]] + self.assertIn(SetReportNumber, event_types, "Sets report_num") + self.assertIn(SetMSCClassification, event_types, "Sets msc") + self.assertEqual(len(event_types), 2, "Only two events are generated") + + +class TestMetadata(TestCase): + """Tests for :func:`.metadata`.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id) + mock_load.return_value = (before, []) + data, code, _ = metadata.metadata('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request_with_no_data(self, mock_load): + """POST request has no form data.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock(submission_id=submission_id), [] + ) + data, _, _ = metadata.metadata('POST', MultiDict(), self.session, submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_required_data(self, mock_load, mock_save): + """POST request with title, abstract, and author names.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock( + title='the old title', + abstract='not the abstract that you are looking for', + authors_display='bloggs, j' + ) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'title': 'a new, valid title', + 'abstract': 'this abstract is at least twenty characters long', + 'authors_display': 'j doe, j bloggs' + }) + _, code, _ = metadata.metadata('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + + event_types = [type(ev) for ev in mock_save.call_args[0]] + self.assertIn(SetTitle, event_types, "Sets ui-app title") + self.assertIn(SetAbstract, event_types, "Sets abstract") + self.assertIn(SetAuthors, event_types, "Sets authors") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_unchanged_data(self, mock_load, mock_save): + """POST request with valid but unaltered data.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock( + title='the old title', + abstract='not the abstract that you are looking for', + authors_display='bloggs, j' + ) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'title': 'the old title', + 'abstract': 'not the abstract that you are looking for', + 'authors_display': 'bloggs, j' + }) + _, code, _ = metadata.metadata('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertEqual(mock_save.call_count, 0, "No events are generated") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_some_changed_data(self, mock_load, mock_save): + """POST request with valid data; only the title has changed.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock( + title='the old title', + abstract='not the abstract that you are looking for', + authors_display='bloggs, j' + ) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'title': 'the new title', + 'abstract': 'not the abstract that you are looking for', + 'authors_display': 'bloggs, j' + }) + _, code, _ = metadata.metadata('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertEqual(mock_save.call_count, 1, "One event is generated") + self.assertIsInstance(mock_save.call_args[0][0], SetTitle, + "SetTitle is generated") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_invalid_data(self, mock_load, mock_save): + """POST request with invalid data.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock( + title='the old title', + abstract='not the abstract that you are looking for', + authors_display='bloggs, j' + ) + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + params = MultiDict({ + 'title': 'the new title', + 'abstract': 'too short', + 'authors_display': 'bloggs, j' + }) + data, _, _ = metadata.metadata('POST', params, self.session, submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) + @mock.patch(f'{metadata.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_error_is_raised(self, mock_load, mock_save): + """POST request results in an SaveError exception.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + is_finalized=False, + metadata=mock.MagicMock( + title='the old title', + abstract='not the abstract that you are looking for', + authors_display='bloggs, j' + ) + ) + mock_load.return_value = (mock_submission, []) + + def raise_save_error(*args, **kwargs): + raise events.SaveError('nope') + + mock_save.side_effect = raise_save_error + params = MultiDict({ + 'title': 'a new, valid title', + 'abstract': 'this abstract is at least twenty characters long', + 'authors_display': 'j doe, j bloggs' + }) + with self.assertRaises(InternalServerError): + metadata.metadata('POST', params, self.session, submission_id) diff --git a/submit/controllers/ui/new/tests/test_policy.py b/submit/controllers/ui/new/tests/test_policy.py new file mode 100644 index 0000000..36c5051 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_policy.py @@ -0,0 +1,176 @@ +"""Tests for :mod:`submit.controllers.policy`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from unittest import TestCase, mock + +from pytz import timezone +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form + +import arxiv.submission as events +from arxiv.submission.domain.event import ConfirmPolicy +from arxiv_auth import auth, domain +from submit.controllers.ui.new import policy + +from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS + +class TestConfirmPolicy(TestCase): + """Test behavior of :func:`.policy` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=False) + mock_load.return_value = (before, []) + data = MultiDict() + + data, code, _ = policy.policy('GET', data, self.session, submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + with self.assertRaises(NotFound): + policy.policy('GET', MultiDict(), self.session, submission_id) + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=False) + mock_load.return_value = (before, []) + + params = MultiDict() + data, _, _ = policy.policy('POST', params, self.session, submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_not_author_no_proxy(self, mock_load): + """User indicates they are not author, but also not proxy.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=False) + mock_load.return_value = (before, []) + params = MultiDict({}) + data, _, _ = policy.policy('POST', params, self.session, submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{policy.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `policy` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=False) + after = mock.MagicMock(submission_id=submission_id, is_finalized=False) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_url_for.return_value = 'https://foo.bar.com/yes' + + params = MultiDict({'policy': 'y', 'action': 'next'}) + data, code, _ = policy.policy('POST', params, self.session, submission_id) + self.assertEqual(code, status.OK) + self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) + + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{policy.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data_already_accepted(self, mock_load, mock_save, mock_url_for): + """POST request with `policy` y and already set on the ui-app.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=True) + after = mock.MagicMock(submission_id=submission_id, is_finalized=False) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_url_for.return_value = 'https://foo.bar.com/yes' + + params = MultiDict({'policy': 'y', 'action': 'next'}) + data, code, _ = policy.policy('POST', params, self.session, submission_id) + self.assertEqual(code, status.OK) + self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) + + @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{policy.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_fails(self, mock_load, mock_save, mock_url_for): + """Event store flakes out on saving policy acceptance.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_accepts_policy=False) + mock_load.return_value = (before, []) + + # Event store does not complain; returns object with `submission_id` + def raise_on_policy(*ev, **kwargs): + if type(ev[0]) is ConfirmPolicy: + raise events.SaveError('the end of the world as we know it') + ident = kwargs.get('submission_id', 2) + return (mock.MagicMock(submission_id=ident), []) + + mock_save.side_effect = raise_on_policy + params = MultiDict({'policy': 'y', 'action': 'next'}) + try: + policy.policy('POST', params, self.session, 2) + self.fail('InternalServerError not raised') + except InternalServerError as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_primary.py b/submit/controllers/ui/new/tests/test_primary.py new file mode 100644 index 0000000..f97d011 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_primary.py @@ -0,0 +1,155 @@ +"""Tests for :mod:`submit.controllers.classification`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from unittest import TestCase, mock + +from pytz import timezone +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import Form + +import arxiv.submission as events +from arxiv.submission.domain.event import SetPrimaryClassification +from submit.controllers.ui.new import classification + +from arxiv_auth import auth, domain +from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS + +class TestSetPrimaryClassification(TestCase): + """Test behavior of :func:`.classification` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, submitter_is_author=False, + is_finalized=False, version=1) + mock_load.return_value = (before, []) + params = MultiDict() + data, code, _ = classification.classification('GET', params, + self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_nonexistant_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + + def raise_no_such_submission(*args, **kwargs): + raise events.exceptions.NoSuchSubmission('Nada') + + mock_load.side_effect = raise_no_such_submission + with self.assertRaises(NotFound): + classification.classification('GET', MultiDict(), self.session, + submission_id) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, submitter_is_author=False, + is_finalized=False, version=1) + mock_load.return_value = (before, []) + data, _, _ = classification.classification('POST', MultiDict(), self.session, + submission_id) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `classification` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, submitter_is_author=False, + is_finalized=False, version=1) + mock_clsn = mock.MagicMock(category='astro-ph.CO') + after = mock.MagicMock(submission_id=submission_id, is_announced=False, + arxiv_id=None, submitter_is_author=False, + primary_classification=mock_clsn, + is_finalized=False, version=1) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_url_for.return_value = 'https://foo.bar.com/yes' + + params = MultiDict({'category': 'astro-ph.CO', 'action': 'next'}) + data, code, _ = classification.classification('POST', params, + self.session, submission_id) + self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) + + @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', + False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{classification.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_error(self, mock_load, mock_save, mock_url_for): + """Event store flakes out on saving classification event.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, submitter_is_author=False, + is_finalized=False, version=1) + mock_load.return_value = (before, []) + + # Event store does not complain; returns object with `submission_id` + def raise_on_set(*ev, **kwargs): + if type(ev[0]) is SetPrimaryClassification: + raise events.SaveError('never get back') + ident = kwargs.get('submission_id', 2) + return (mock.MagicMock(submission_id=ident), []) + + mock_save.side_effect = raise_on_set + params = MultiDict({'category': 'astro-ph.CO', 'action': 'next'}) + try: + classification.classification('POST', params, self.session, 2) + self.fail('InternalServerError not raised') + except InternalServerError as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_unsubmit.py b/submit/controllers/ui/new/tests/test_unsubmit.py new file mode 100644 index 0000000..ea9294e --- /dev/null +++ b/submit/controllers/ui/new/tests/test_unsubmit.py @@ -0,0 +1,101 @@ +"""Tests for :mod:`submit.controllers.unsubmit`.""" + +from unittest import TestCase, mock +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import BadRequest +from wtforms import Form +from http import HTTPStatus as status +from submit.controllers.ui.new import unsubmit + +from pytz import timezone +from datetime import timedelta, datetime +from arxiv_auth import auth, domain + + +class TestUnsubmit(TestCase): + """Test behavior of :func:`.unsubmit` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=True, + submitter_contact_verified=False) + mock_load.return_value = (before, []) + data, code, _ = unsubmit.unsubmit('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=True, + submitter_contact_verified=False) + mock_load.return_value = (before, []) + params = MultiDict() + try: + unsubmit.unsubmit('POST', params, self.session, submission_id) + self.fail('BadRequest not raised') + except BadRequest as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) + @mock.patch(f'{unsubmit.__name__}.url_for') + @mock.patch('arxiv.base.alerts.flash_success') + @mock.patch(f'{unsubmit.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, + mock_flash_success, mock_url_for): + """POST request with `confirmed` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=True, is_announced=False) + after = mock.MagicMock(submission_id=submission_id, + is_finalized=False, is_announced=False) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_flash_success.return_value = None + mock_url_for.return_value = 'https://foo.bar.com/yes' + + form_data = MultiDict({'confirmed': True}) + _, code, _ = unsubmit.unsubmit('POST', form_data, self.session, + submission_id) + self.assertEqual(code, status.SEE_OTHER, "Returns redirect") diff --git a/submit/controllers/ui/new/tests/test_upload.py b/submit/controllers/ui/new/tests/test_upload.py new file mode 100644 index 0000000..5dd2e71 --- /dev/null +++ b/submit/controllers/ui/new/tests/test_upload.py @@ -0,0 +1,334 @@ +"""Tests for :mod:`submit.controllers.upload`.""" + +from datetime import timedelta, datetime +from http import HTTPStatus as status +from pytz import timezone +from unittest import TestCase, mock + +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import BadRequest, InternalServerError +from wtforms import Form + +from arxiv_auth import auth, domain +from arxiv.submission.domain.submission import SubmissionContent +from arxiv.submission.domain.uploads import Upload, FileStatus, FileError, \ + UploadLifecycleStates, UploadStatus +from arxiv.submission.services import filemanager + +from submit.controllers.ui.new import upload +from submit.controllers.ui.new import upload_delete + +from submit.routes.ui.flow_control import STAGE_SUCCESS, \ + get_controllers_desire, STAGE_RESHOW + +class TestUpload(TestCase): + """Tests for :func:`submit.controllers.upload`.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName('Jane', 'Bloggs', 'III'), + profile=domain.UserProfile( + affiliation='FSU', + rank=3, + country='de', + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_no_upload(self, mock_load): + """GET request for ui-app with no upload package.""" + submission_id = 2 + subm = mock.MagicMock(submission_id=submission_id, source_content=None, + is_finalized=False, is_announced=False, + arxiv_id=None, version=1) + mock_load.return_value = (subm, []) + params = MultiDict({}) + files = MultiDict({}) + data, code, _ = upload.upload_files('GET', params, files, self.session, + submission_id, 'footoken') + self.assertEqual(code, status.OK, 'Returns 200 OK') + self.assertIn('ui-app', data, 'Submission is in response') + self.assertIn('submission_id', data, 'ID is in response') + + @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) + @mock.patch(f'{upload.__name__}.alerts', mock.MagicMock()) + @mock.patch(f'{upload.__name__}.Filemanager') + @mock.patch('arxiv.submission.load') + def test_get_upload(self, mock_load, mock_Filemanager): + """GET request for ui-app with an existing upload package.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier='5433', + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, + version=1 + ), [] + ) + mock_filemanager = mock.MagicMock() + mock_filemanager.get_upload_status.return_value = ( + Upload( + identifier=25, + checksum='a1s2d3f4', + size=593920, + started=datetime.now(), + completed=datetime.now(), + created=datetime.now(), + modified=datetime.now(), + status=UploadStatus.READY, + lifecycle=UploadLifecycleStates.ACTIVE, + locked=False, + files=[FileStatus( + path='', + name='thebestfile.pdf', + file_type='PDF', + modified=datetime.now(), + size=20505, + ancillary=False, + errors=[] + )], + errors=[] + ) + ) + mock_Filemanager.current_session.return_value = mock_filemanager + params = MultiDict({}) + files = MultiDict({}) + data, code, _ = upload.upload_files('GET', params, self.session, + submission_id, files=files, + token='footoken') + self.assertEqual(code, status.OK, 'Returns 200 OK') + self.assertEqual(mock_filemanager.get_upload_status.call_count, 1, + 'Calls the file management service') + self.assertIn('status', data, 'Upload status is in response') + self.assertIn('ui-app', data, 'Submission is in response') + self.assertIn('submission_id', data, 'ID is in response') + + @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) + @mock.patch(f'{upload.__name__}.alerts', mock.MagicMock()) + @mock.patch(f'{upload.__name__}.url_for', mock.MagicMock(return_value='/')) + @mock.patch(f'{upload.__name__}.Filemanager') + @mock.patch(f'{upload.__name__}.save') + @mock.patch(f'arxiv.submission.load') + def test_post_upload(self, mock_load, mock_save, mock_filemanager): + """POST request for ui-app with an existing upload package.""" + submission_id = 2 + mock_submission = mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier='5433', + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, version=1 + ) + mock_load.return_value = (mock_submission, []) + mock_save.return_value = (mock_submission, []) + mock_fm = mock.MagicMock() + mock_fm.add_file.return_value = Upload( + identifier=25, + checksum='a1s2d3f4', + size=593920, + started=datetime.now(), + completed=datetime.now(), + created=datetime.now(), + modified=datetime.now(), + status=UploadStatus.READY, + lifecycle=UploadLifecycleStates.ACTIVE, + locked=False, + files=[FileStatus( + path='', + name='thebestfile.pdf', + file_type='PDF', + modified=datetime.now(), + size=20505, + ancillary=False, + errors=[] + )], + errors=[] + ) + mock_filemanager.current_session.return_value = mock_fm + params = MultiDict({}) + mock_file = mock.MagicMock() + files = MultiDict({'file': mock_file}) + data, code, _ = upload.upload_files('POST', params, self.session, + submission_id, files=files, + token='footoken') + + self.assertEqual(code, status.OK) + self.assertEqual(get_controllers_desire(data), STAGE_RESHOW, + 'Successful upload and reshow form') + self.assertEqual(mock_fm.add_file.call_count, 1, + 'Calls the file management service') + self.assertTrue(mock_filemanager.add_file.called_with(mock_file)) + + +class TestDelete(TestCase): + """Tests for :func:`submit.controllers.upload.delete`.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName('Jane', 'Bloggs', 'III'), + profile=domain.UserProfile( + affiliation='FSU', + rank=3, + country='de', + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) + @mock.patch(f'{upload_delete.__name__}.Filemanager') + @mock.patch('arxiv.submission.load') + def test_get_delete(self, mock_load, mock_filemanager): + """GET request to delete a file.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier='5433', + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, + version=1 + ), [] + ) + file_path = 'anc/foo.jpeg' + params = MultiDict({'path': file_path}) + data, code, _ = upload_delete.delete_file('GET', params, self.session, + submission_id, 'footoken') + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIn('form', data, "Returns a form in response") + self.assertEqual(data['form'].file_path.data, file_path, 'Path is set') + + @mock.patch(f'{upload_delete.__name__}.alerts', mock.MagicMock()) + @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) + @mock.patch(f'{upload_delete.__name__}.Filemanager') + @mock.patch('arxiv.submission.load') + def test_post_delete(self, mock_load, mock_filemanager): + """POST request to delete a file without confirmation.""" + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier='5433', + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, + version=1 + ), [] + ) + file_path = 'anc/foo.jpeg' + params = MultiDict({'file_path': file_path}) + try: + upload_delete.delete_file('POST', params, self.session, submission_id, 'tok') + except BadRequest as e: + data = e.description + self.assertIn('form', data, "Returns a form in response") + + @mock.patch(f'{upload_delete.__name__}.alerts', mock.MagicMock()) + @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) + @mock.patch(f'{upload_delete.__name__}.url_for') + @mock.patch(f'{upload_delete.__name__}.Filemanager') + @mock.patch(f'{upload_delete.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_delete_confirmed(self, mock_load, mock_save, + mock_filemanager, mock_url_for): + """POST request to delete a file without confirmation.""" + redirect_uri = '/foo' + mock_url_for.return_value = redirect_uri + upload_id = '5433' + submission_id = 2 + mock_load.return_value = ( + mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier=upload_id, + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, + version=1 + ), [] + ) + mock_save.return_value = ( + mock.MagicMock( + submission_id=submission_id, + source_content=SubmissionContent( + identifier=upload_id, + checksum='a1s2d3f4', + uncompressed_size=593920, + compressed_size=1000, + source_format=SubmissionContent.Format.TEX + ), + is_finalized=False, is_announced=False, arxiv_id=None, + version=1 + ), [] + ) + file_path = 'anc/foo.jpeg' + params = MultiDict({'file_path': file_path, 'confirmed': True}) + data, code, _ = upload_delete.delete_file('POST', params, self.session, submission_id, + 'footoken') + self.assertTrue( + mock_filemanager.delete_file.called_with(upload_id, file_path), + "Delete file method of file manager service is called" + ) + self.assertEqual(code, status.OK) + self.assertTrue(get_controllers_desire(data), STAGE_SUCCESS) diff --git a/submit/controllers/ui/new/tests/test_verify_user.py b/submit/controllers/ui/new/tests/test_verify_user.py new file mode 100644 index 0000000..6576cbd --- /dev/null +++ b/submit/controllers/ui/new/tests/test_verify_user.py @@ -0,0 +1,128 @@ +"""Tests for :mod:`submit.controllers.verify_user`.""" + +from unittest import TestCase, mock +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, BadRequest +from wtforms import Form +from http import HTTPStatus as status +import arxiv.submission as events +from arxiv.submission.domain.event import ConfirmContactInformation +from submit.controllers.ui.new import verify_user + +from pytz import timezone +from datetime import timedelta, datetime +from arxiv_auth import auth, domain + + +class TestVerifyUser(TestCase): + """Test behavior of :func:`.verify_user` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_get_request_with_submission(self, mock_load): + """GET request with a ui-app ID.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_contact_verified=False) + mock_load.return_value = (before, []) + data, code, _ = verify_user.verify('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_post_request(self, mock_load): + """POST request with no data.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_contact_verified=False) + mock_load.return_value = (before, []) + params = MultiDict() + data, code, _ = verify_user.verify('POST', params, self.session, + submission_id) + self.assertEqual(code, status.OK) + self.assertIsInstance(data['form'], Form, "Data includes a form") + + @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{verify_user.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): + """POST request with `verify_user` set.""" + # Event store does not complain; returns object with `submission_id`. + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_contact_verified=False) + after = mock.MagicMock(submission_id=submission_id, is_finalized=False, + submitter_contact_verified=True) + mock_load.return_value = (before, []) + mock_save.return_value = (after, []) + mock_url_for.return_value = 'https://foo.bar.com/yes' + + form_data = MultiDict({'verify_user': 'y', 'action': 'next'}) + _, code, _ = verify_user.verify('POST', form_data, self.session, + submission_id) + self.assertEqual(code, status.OK,) + + @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) + @mock.patch('submit.controllers.ui.util.url_for') + @mock.patch(f'{verify_user.__name__}.save') + @mock.patch('arxiv.submission.load') + def test_save_fails(self, mock_load, mock_save, mock_url_for): + """Event store flakes out saving authorship verification.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_finalized=False, + submitter_contact_verified=False) + mock_load.return_value = (before, []) + + # Event store does not complain; returns object with `submission_id` + def raise_on_verify(*ev, **kwargs): + if type(ev[0]) is ConfirmContactInformation: + raise events.SaveError('not today') + ident = kwargs.get('submission_id', 2) + return (mock.MagicMock(submission_id=ident, + submitter_contact_verified=False), []) + + mock_save.side_effect = raise_on_verify + params = MultiDict({'verify_user': 'y', 'action': 'next'}) + try: + verify_user.verify('POST', params, self.session, 2) + self.fail('InternalServerError not raised') + except InternalServerError as e: + data = e.description + self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/unsubmit.py b/submit/controllers/ui/new/unsubmit.py new file mode 100644 index 0000000..01c5fc2 --- /dev/null +++ b/submit/controllers/ui/new/unsubmit.py @@ -0,0 +1,59 @@ +"""Provide the controller used to unsubmit/unfinalize a ui-app.""" + +from http import HTTPStatus as status + +from flask import url_for +from wtforms import BooleanField, validators +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import BadRequest, InternalServerError + +from arxiv.base import alerts +from arxiv.forms import csrf +from arxiv.submission import save +from arxiv.submission.domain.event import UnFinalizeSubmission +from arxiv_auth.domain import Session + +from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + +class UnsubmitForm(csrf.CSRFForm): + """Form for unsubmitting a ui-app.""" + + confirmed = BooleanField('Confirmed', + validators=[validators.DataRequired()]) + + +def unsubmit(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Unsubmit a ui-app.""" + submission, submission_events = load_submission(submission_id) + response_data = { + 'ui-app': submission, + 'submission_id': submission.submission_id, + } + + if method == 'GET': + form = UnsubmitForm() + response_data.update({'form': form}) + return response_data, status.OK, {} + elif method == 'POST': + form = UnsubmitForm(params) + response_data.update({'form': form}) + if form.validate() and form.confirmed.data: + user, client = user_and_client_from_session(session) + command = UnFinalizeSubmission(creator=user, client=client) + if not validate_command(form, command, submission, 'confirmed'): + raise BadRequest(response_data) + + try: + save(command, submission_id=submission_id) + except Exception as e: + alerts.flash_failure("Whoops!") + raise InternalServerError(response_data) from e + alerts.flash_success("Unsubmitted.") + redirect = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': redirect} + response_data.update({'form': form}) + # TODO not updated to non-BadRequest convention + raise BadRequest(response_data) diff --git a/submit/controllers/ui/new/upload.py b/submit/controllers/ui/new/upload.py new file mode 100644 index 0000000..3efc076 --- /dev/null +++ b/submit/controllers/ui/new/upload.py @@ -0,0 +1,548 @@ +""" +Controllers for upload-related requests. + +Things that still need to be done: + +- Display error alerts from the file management service. +- Show warnings/errors for individual files in the table. We may need to + extend the flashing mechanism to "flash" data to the next page (without + displaying it as a notification to the user). + +""" + +import traceback +from collections import OrderedDict +from http import HTTPStatus as status +from locale import strxfrm +from typing import Tuple, Dict, Any, Optional, List, Union, Mapping + +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import ( + InternalServerError, + BadRequest, + MethodNotAllowed, + RequestEntityTooLarge +) +from wtforms import BooleanField, HiddenField, FileField +from wtforms.validators import DataRequired + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv.integration.api import exceptions +from arxiv.submission import save, Submission, User, Client, Event +from arxiv.submission.services import Filemanager +from arxiv.submission.domain.uploads import Upload, FileStatus, UploadStatus +from arxiv.submission.domain.submission import SubmissionContent +from arxiv.submission.domain.event import SetUploadPackage, UpdateUploadPackage +from arxiv.submission.exceptions import SaveError +from arxiv_auth.domain import Session + +from submit.controllers.ui.util import ( + validate_command, + user_and_client_from_session +) +from submit.util import load_submission, tidy_filesize +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage +from submit.controllers.ui.util import add_immediate_alert + +logger = logging.getLogger(__name__) + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + +PLEASE_CONTACT_SUPPORT = Markup( + 'If you continue to experience problems, please contact' + ' arXiv support.' +) + + +def upload_files(method: str, params: MultiDict, session: Session, + submission_id: int, files: Optional[MultiDict] = None, + token: Optional[str] = None, **kwargs) -> Response: + """Handle a file upload request. + + GET requests are treated as a request for information about the current + state of the ui-app upload. + + POST requests are treated either as package upload if the upload + workspace does not already exist or a request to replace a file. + + Parameters + ---------- + method : str + ``GET`` or ``POST`` + params : :class:`MultiDict` + The form data from the request. + files : :class:`MultiDict` + File data in the multipart request. Values should be + :class:`FileStorage` instances. + session : :class:`Session` + The authenticated session for the request. + submission_id : int + The identifier of the ui-app for which the upload is being made. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``200`` or ``303``, unless something + goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response, if + applicable. + + """ + rdata = {} + if files is None or token is None: + add_immediate_alert(rdata, alerts.FAILURE, + 'Missing auth files or token') + return stay_on_this_stage((rdata, status.OK, {})) + + submission, _ = load_submission(submission_id) + + rdata.update({'submission_id': submission_id, + 'ui-app': submission, + 'form': UploadForm()}) + + if method not in ['GET', 'POST']: + raise MethodNotAllowed() + elif method == 'GET': + logger.debug('GET; load current upload state') + return _get_upload(params, session, submission, rdata, token) + elif method == 'POST': + try: # Make sure that we have a file to work with. + pointer = files['file'] + except KeyError: # User is going back, saving/exiting, or next step. + pointer = None + + if not pointer: + # Don't flash a message if the user is just trying to go back to the + # previous page. + logger.debug('No files on request') + action = params.get('action', None) + if action: + logger.debug('User is navigating away from upload UI') + return {}, status.SEE_OTHER, {} + else: + return stay_on_this_stage(_get_upload(params, session, + submission, rdata, token)) + + try: + if submission.source_content is None: + logger.debug('New upload package') + return _new_upload(params, pointer, session, submission, rdata, token) + else: + logger.debug('Adding additional files') + return _new_file(params, pointer, session, submission, rdata, token) + except exceptions.ConnectionFailed as ex: + logger.debug('Problem POSTing upload: %s', ex) + alerts.flash_failure(Markup( + 'There was a problem uploading your file. ' + f'{PLEASE_CONTACT_SUPPORT}')) + except RequestEntityTooLarge as ex: + logger.debug('Problem POSTing upload: %s', ex) + alerts.flash_failure(Markup( + 'There was a problem uploading your file because it exceeds ' + f'our maximum size limit. {PLEASE_CONTACT_SUPPORT}')) + return stay_on_this_stage(_get_upload(params, session, submission, rdata, token)) + + +class UploadForm(csrf.CSRFForm): + """Form for uploading files.""" + + file = FileField('Choose a file...') + ancillary = BooleanField('Ancillary') + + +def _update(form: UploadForm, submission: Submission, stat: Upload, + submitter: User, client: Optional[Client] = None) \ + -> Optional[Submission]: + """ + Update the :class:`.Submission` after an upload-related action. + + The ui-app is linked to the upload workspace via the + :attr:`Submission.source_content` attribute. This is set using a + :class:`SetUploadPackage` command. If the workspace identifier changes + (e.g. on first upload), we want to execute :class:`SetUploadPackage` to + make the association. + + Parameters + ---------- + form : WTForm for adding validation error messages + submission : :class:`Submission` + stat : :class:`Upload` + submitter : :class:`User` + client : :class:`Client` or None + + """ + existing_upload = getattr(submission.source_content, 'identifier', None) + + command: Event + if existing_upload == stat.identifier: + command = UpdateUploadPackage(creator=submitter, client=client, + checksum=stat.checksum, + uncompressed_size=stat.size, + compressed_size=stat.compressed_size, + source_format=stat.source_format) + else: + command = SetUploadPackage(creator=submitter, client=client, + identifier=stat.identifier, + checksum=stat.checksum, + compressed_size=stat.compressed_size, + uncompressed_size=stat.size, + source_format=stat.source_format) + + if not validate_command(form, command, submission): + return None + + try: + submission, _ = save(command, submission_id=submission.submission_id) + except SaveError: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + return submission + + +def _get_upload(params: MultiDict, session: Session, submission: Submission, + rdata: Dict[str, Any], token) -> Response: + """ + Get the current state of the upload workspace, and prepare a response. + + Parameters + ---------- + params : :class:`MultiDict` + The query parameters from the request. + session : :class:`Session` + The authenticated session for the request. + submission : :class:`Submission` + The ui-app for which to retrieve upload workspace information. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. + dict + Extra headers to add/update on the response. + + """ + rdata.update({'status': None, 'form': UploadForm()}) + + if submission.source_content is None: + # Nothing to show; should generate a blank-slate upload screen. + return rdata, status.OK, {} + + fm = Filemanager.current_session() + + upload_id = submission.source_content.identifier + status_data = alerts.get_hidden_alerts('_status') + if type(status_data) is dict and status_data['identifier'] == upload_id: + stat = Upload.from_dict(status_data) + else: + try: + stat = fm.get_upload_status(upload_id, token) + except exceptions.RequestFailed as ex: + # TODO: handle specific failure cases. + logger.debug('Failed to get upload status: %s', ex) + logger.error(traceback.format_exc()) + raise InternalServerError(rdata) from ex + rdata.update({'status': stat}) + if stat: + rdata.update({'immediate_notifications': _get_notifications(stat)}) + return rdata, status.OK, {} + + +def _new_upload(params: MultiDict, pointer: FileStorage, session: Session, + submission: Submission, rdata: Dict[str, Any], token: str) \ + -> Response: + """ + Handle a POST request with a new upload package. + + This occurs in the case that there is not already an upload workspace + associated with the ui-app. See the :attr:`Submission.source_content` + attribute, which is set using :class:`SetUploadPackage`. + + Parameters + ---------- + params : :class:`MultiDict` + The form data from the request. + pointer : :class:`FileStorage` + The file upload stream. + session : :class:`Session` + The authenticated session for the request. + submission : :class:`Submission` + The ui-app for which the upload is being made. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``303``, unless something goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response. + + """ + submitter, client = user_and_client_from_session(session) + fm = Filemanager.current_session() + + params['file'] = pointer + form = UploadForm(params) + rdata.update({'form': form}) + + if not form.validate(): + logger.debug('Invalid form data') + return stay_on_this_stage((rdata, status.OK, {})) + + try: + stat = fm.upload_package(pointer, token) + except exceptions.RequestFailed as ex: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + logger.debug('Failed to upload package: %s', ex) + logger.error(traceback.format_exc()) + raise InternalServerError(rdata) from ex + + submission = _update(form, submission, stat, submitter, client) + converted_size = tidy_filesize(stat.size) + if stat.status is UploadStatus.READY: + alerts.flash_success( + f'Unpacked {stat.file_count} files. Total ui-app' + f' package size is {converted_size}', + title='Upload successful' + ) + elif stat.status is UploadStatus.READY_WITH_WARNINGS: + alerts.flash_warning( + f'Unpacked {stat.file_count} files. Total ui-app' + f' package size is {converted_size}. See below for warnings.', + title='Upload complete, with warnings' + ) + elif stat.status is UploadStatus.ERRORS: + alerts.flash_warning( + f'Unpacked {stat.file_count} files. Total ui-app' + f' package size is {converted_size}. See below for errors.', + title='Upload complete, with errors' + ) + alerts.flash_hidden(stat.to_dict(), '_status') + + rdata.update({'status': stat}) + return stay_on_this_stage((rdata, status.OK, {})) + +# loc = url_for('ui.file_upload', submission_id=ui-app.submission_id) +# return {}, status.SEE_OTHER, {'Location': loc} + + +def _new_file(params: MultiDict, pointer: FileStorage, session: Session, + submission: Submission, rdata: Dict[str, Any], token: str) \ + -> Response: + """ + Handle a POST request with a new file to add to an existing upload package. + + This occurs in the case that there is already an upload workspace + associated with the ui-app. See the :attr:`Submission.source_content` + attribute, which is set using :class:`SetUploadPackage`. + + Parameters + ---------- + params : :class:`MultiDict` + The form data from the request. + pointer : :class:`FileStorage` + The file upload stream. + session : :class:`Session` + The authenticated session for the request. + submission : :class:`Submission` + The ui-app for which the upload is being made. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``303``, unless something goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response. + + """ + submitter, client = user_and_client_from_session(session) + fm = Filemanager.current_session() + upload_id = submission.source_content.identifier + + # Using a form object provides some extra assurance that this is a legit + # request; provides CSRF goodies. + params['file'] = pointer + form = UploadForm(params) + rdata.update({'form': form, 'ui-app': submission}) + + if not form.validate(): + logger.error('Invalid upload form: %s', form.errors) + alerts.flash_failure( + "No file was uploaded; please try again.", + title="Something went wrong") + return stay_on_this_stage((rdata, status.OK, {})) + + ancillary: bool = form.ancillary.data + + try: + stat = fm.add_file(upload_id, pointer, token, ancillary=ancillary) + except exceptions.RequestFailed as ex: + try: + ex_data = ex.response.json() + except Exception: + ex_data = None + if ex_data is not None and 'reason' in ex_data: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request:' + f' {ex_data["reason"]}. {PLEASE_CONTACT_SUPPORT}' + )) + return stay_on_this_stage((rdata, status.OK, {})) + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + logger.debug('Failed to add file: %s', ) + logger.error(traceback.format_exc()) + raise InternalServerError(rdata) from ex + + submission = _update(form, submission, stat, submitter, client) + converted_size = tidy_filesize(stat.size) + if stat.status is UploadStatus.READY: + alerts.flash_success( + f'Uploaded {pointer.filename} successfully. Total ui-app' + f' package size is {converted_size}', + title='Upload successful' + ) + elif stat.status is UploadStatus.READY_WITH_WARNINGS: + alerts.flash_warning( + f'Uploaded {pointer.filename} successfully. Total ui-app' + f' package size is {converted_size}. See below for warnings.', + title='Upload complete, with warnings' + ) + elif stat.status is UploadStatus.ERRORS: + alerts.flash_warning( + f'Uploaded {pointer.filename} successfully. Total ui-app' + f' package size is {converted_size}. See below for errors.', + title='Upload complete, with errors' + ) + status_data = stat.to_dict() + alerts.flash_hidden(status_data, '_status') + rdata.update({'status': stat}) + return stay_on_this_stage((rdata, status.OK, {})) + + +def _get_notifications(stat: Upload) -> List[Dict[str, str]]: + # TODO: these need wordsmithing. + notifications = [] + if not stat.files: # Nothing in the upload workspace. + return notifications + if stat.status is UploadStatus.ERRORS: + notifications.append({ + 'title': 'Unresolved errors', + 'severity': 'danger', + 'body': 'There are unresolved problems with your ui-app' + ' files. Please correct the errors below before' + ' proceeding.' + }) + elif stat.status is UploadStatus.READY_WITH_WARNINGS: + notifications.append({ + 'title': 'Warnings', + 'severity': 'warning', + 'body': 'There is one or more unresolved warning in the file list.' + ' You may proceed with your ui-app, but please note' + ' that these issues may cause delays in processing' + ' and/or announcement.' + }) + if stat.source_format is SubmissionContent.Format.UNKNOWN: + notifications.append({ + 'title': 'Unknown ui-app type', + 'severity': 'warning', + 'body': 'We could not determine the source type of your' + ' ui-app. Please check your files carefully. We may' + ' not be able to process your files.' + }) + elif stat.source_format is SubmissionContent.Format.INVALID: + notifications.append({ + 'title': 'Unsupported ui-app type', + 'severity': 'danger', + 'body': 'It is likely that your ui-app content is not' + ' supported. Please check your files carefully. We may not' + ' be able to process your files.' + }) + else: + notifications.append({ + 'title': f'Detected {stat.source_format.value.upper()}', + 'severity': 'success', + 'body': 'Your ui-app content is supported.' + }) + return notifications + + +def group_files(files: List[FileStatus]) -> OrderedDict: + """Group a set of file status objects by directory structure. + + Parameters + ---------- + list + Elements are :class:`FileStatus` objects. + + Returns ------- :class:`OrderedDict` Keys are strings of either + file or directory names. Values are either :class:`FileStatus` + instances (leaves) or :class:`OrderedDict` (containing more + :class:`FileStatus` and/or :class:`OrderedDict`, etc). + + """ + # First step is to organize by file tree. + tree = {} + for f in files: + parts = f.path.split('/') + if len(parts) == 1: + tree[f.name] = f + else: + subtree = tree + for part in parts[:-1]: + if part not in subtree: + subtree[part] = {} + subtree = subtree[part] + subtree[parts[-1]] = f + + # Reorder subtrees for nice display. + def _order(node: Union[dict, FileStatus]) -> OrderedDict: + if type(node) is FileStatus: + return node + + in_subtree: dict = node + + # split subtree into FileStatus and other + filestats = [fs for key, fs in in_subtree.items() + if type(fs) is FileStatus] + deeper_subtrees = [(key, st) for key, st in in_subtree.items() + if type(st) is not FileStatus] + + # add the files at this level before any subtrees + ordered_subtree = OrderedDict() + if filestats and filestats is not None: + for fs in sorted(filestats, + key=lambda fs: strxfrm(fs.path.casefold())): + ordered_subtree[fs.path] = fs + + if deeper_subtrees: + for key, deeper in sorted(deeper_subtrees, + key=lambda tup: strxfrm( + tup[0].casefold())): + ordered_subtree[key] = _order(deeper) + + return ordered_subtree + + return _order(tree) diff --git a/submit/controllers/ui/new/upload_delete.py b/submit/controllers/ui/new/upload_delete.py new file mode 100644 index 0000000..9d0abf6 --- /dev/null +++ b/submit/controllers/ui/new/upload_delete.py @@ -0,0 +1,251 @@ +""" +Controllers for file-delete-related requests. +""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv.integration.api import exceptions +from arxiv.submission import save +from arxiv.submission.domain.event import UpdateUploadPackage +from arxiv.submission.domain.uploads import Upload +from arxiv.submission.exceptions import SaveError +from arxiv.submission.services import Filemanager +from arxiv_auth.domain import Session +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import BadRequest, MethodNotAllowed +from wtforms import BooleanField, HiddenField +from wtforms.validators import DataRequired + +from submit.controllers.ui.util import validate_command, \ + user_and_client_from_session +from submit.util import load_submission +from submit.routes.ui.flow_control import ready_for_next, \ + stay_on_this_stage, return_to_parent_stage +from submit.controllers.ui.util import add_immediate_alert + +logger = logging.getLogger(__name__) + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + +PLEASE_CONTACT_SUPPORT = Markup( + 'If you continue to experience problems, please contact' + ' arXiv support.' +) + + +def delete_all(method: str, params: MultiDict, session: Session, + submission_id: int, token: Optional[str] = None, + **kwargs) -> Response: + """ + Handle a request to delete all files in the workspace. + + Parameters + ---------- + method : str + ``GET`` or ``POST`` + params : :class:`MultiDict` + The query or form data from the request. + session : :class:`Session` + The authenticated session for the request. + submission_id : int + The identifier of the ui-app for which the deletion is being made. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + + Returns + ------- + dict + int + Response data, to render in template. + HTTP status code. This should be ``200`` or ``303``, unless something + goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response, if + applicable. + + """ + rdata = {} + if token is None: + add_immediate_alert(rdata, alerts.FAILURE, 'Missing auth token') + return stay_on_this_stage((rdata, status.OK, {})) + + fm = Filemanager.current_session() + submission, submission_events = load_submission(submission_id) + upload_id = submission.source_content.identifier + submitter, client = user_and_client_from_session(session) + rdata.update({'ui-app': submission, 'submission_id': submission_id}) + + if method == 'GET': + form = DeleteAllFilesForm() + rdata.update({'form': form}) + return stay_on_this_stage((rdata, status.OK, {})) + + elif method == 'POST': + form = DeleteAllFilesForm(params) + rdata.update({'form': form}) + + if not (form.validate() and form.confirmed.data): + return stay_on_this_stage((rdata, status.OK, {})) + + try: + stat = fm.delete_all(upload_id, token) + except exceptions.RequestForbidden as e: + alerts.flash_failure(Markup( + 'There was a problem authorizing your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + logger.error('Encountered RequestForbidden: %s', e) + except exceptions.BadRequest as e: + alerts.flash_warning(Markup( + 'Something odd happened when processing your request.' + f'{PLEASE_CONTACT_SUPPORT}' + )) + logger.error('Encountered BadRequest: %s', e) + except exceptions.RequestFailed as e: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + logger.error('Encountered RequestFailed: %s', e) + + command = UpdateUploadPackage(creator=submitter, client=client, + checksum=stat.checksum, + uncompressed_size=stat.size, + source_format=stat.source_format) + if not validate_command(form, command, submission): + logger.debug('Command validation failed') + return return_to_parent_stage((rdata, status.OK, {})) + + try: + submission, _ = save(command, submission_id=submission_id) + except SaveError: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + + return return_to_parent_stage((rdata, status.OK, {})) + + raise MethodNotAllowed('Method not supported') + + +def delete_file(method: str, params: MultiDict, session: Session, + submission_id: int, token: Optional[str] = None, + **kwargs) -> Response: + """ + Handle a request to delete a file. + + The file will only be deleted if a POST request is made that also contains + the ``confirmed`` parameter. + + The process can be initiated with a GET request that contains the + ``path`` (key) for the file to be deleted. For example, a button on + the upload interface may link to the deletion route with the file path + as a query parameter. This will generate a deletion confirmation form, + which can be POSTed to complete the action. + + Parameters + ---------- + method : str + ``GET`` or ``POST`` + params : :class:`MultiDict` + The query or form data from the request. + session : :class:`Session` + The authenticated session for the request. + submission_id : int + The identifier of the ui-app for which the deletion is being made. + token : str + The original (encrypted) auth token on the request. Used to perform + subrequests to the file management service. + + Returns + ------- + dict + Response data, to render in template. + int + HTTP status code. This should be ``200`` or ``303``, unless something + goes wrong. + dict + Extra headers to add/update on the response. This should include + the `Location` header for use in the 303 redirect response, if + applicable. + + """ + rdata = {} + if token is None: + add_immediate_alert(rdata, alerts.FAILURE, 'Missing auth token') + return stay_on_this_stage((rdata, status.OK, {})) + + fm = Filemanager.current_session() + submission, submission_events = load_submission(submission_id) + upload_id = submission.source_content.identifier + submitter, client = user_and_client_from_session(session) + + rdata = {'ui-app': submission, 'submission_id': submission_id} + + if method == 'GET': + # The only thing that we want to get from the request params on a GET + # request is the file path. This way there is no way for a GET request + # to trigger actual deletion. The user must explicitly indicate via + # a valid POST that the file should in fact be deleted. + params = MultiDict({'file_path': params['path']}) + + form = DeleteFileForm(params) + rdata.update({'form': form}) + + if method == 'POST': + if not (form.validate() and form.confirmed.data): + logger.debug('Invalid form data') + return stay_on_this_stage((rdata, status.OK, {})) + + stat: Optional[Upload] = None + try: + file_path = form.file_path.data + stat = fm.delete_file(upload_id, file_path, token) + alerts.flash_success( + f'File {form.file_path.data} was deleted' + ' successfully', title='Deleted file successfully', + safe=True + ) + except (exceptions.RequestForbidden, exceptions.BadRequest, exceptions.RequestFailed): + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + + if stat is not None: + command = UpdateUploadPackage(creator=submitter, + checksum=stat.checksum, + uncompressed_size=stat.size, + source_format=stat.source_format) + if not validate_command(form, command, submission): + logger.debug('Command validation failed') + return stay_on_this_stage((rdata, status.OK, {})) + try: + submission, _ = save(command, submission_id=submission_id) + except SaveError: + alerts.flash_failure(Markup( + 'There was a problem carrying out your request. Please try' + f' again. {PLEASE_CONTACT_SUPPORT}' + )) + return return_to_parent_stage(({}, status.OK, {})) + return stay_on_this_stage((rdata, status.OK, {})) + + +class DeleteFileForm(csrf.CSRFForm): + """Form for deleting individual files.""" + + file_path = HiddenField('File', validators=[DataRequired()]) + confirmed = BooleanField('Confirmed', validators=[DataRequired()]) + + +class DeleteAllFilesForm(csrf.CSRFForm): + """Form for deleting all files in the workspace.""" + + confirmed = BooleanField('Confirmed', validators=[DataRequired()]) diff --git a/submit/controllers/ui/new/verify_user.py b/submit/controllers/ui/new/verify_user.py new file mode 100644 index 0000000..95913ab --- /dev/null +++ b/submit/controllers/ui/new/verify_user.py @@ -0,0 +1,82 @@ +""" +Controller for verify_user action. + +Creates an event of type `core.events.event.ConfirmContactInformation` +""" +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional + +from flask import url_for +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms import BooleanField +from wtforms.validators import InputRequired + +from arxiv.base import logging +from arxiv.forms import csrf +from arxiv_auth.domain import Session +from arxiv.submission import save, SaveError +from arxiv.submission.domain.event import ConfirmContactInformation + +from submit.util import load_submission +from submit.controllers.ui.util import validate_command, \ + user_and_client_from_session +from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +def verify(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """ + Prompt the user to verify their contact information. + + Generates a `ConfirmContactInformation` event when valid data are POSTed. + """ + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + submitter, client = user_and_client_from_session(session) + + # Will raise NotFound if there is no such ui-app. + submission, _ = load_submission(submission_id) + + # Initialize the form with the current state of the ui-app. + if method == 'GET': + if submission.submitter_contact_verified: + params['verify_user'] = 'true' + + form = VerifyUserForm(params) + response_data = { + 'submission_id': submission_id, + 'form': form, + 'ui-app': submission, + 'submitter': submitter, + 'user': session.user, # We want the most up-to-date representation. + } + + if method == 'POST' and form.validate() and form.verify_user.data: + # Now that we have a ui-app, we can verify the user's contact + # information. There is no need to do this more than once. + if submission.submitter_contact_verified: + return ready_for_next((response_data, status.OK,{})) + else: + cmd = ConfirmContactInformation(creator=submitter, client=client) + if validate_command(form, cmd, submission, 'verify_user'): + try: + submission, _ = save(cmd, submission_id=submission_id) + response_data['ui-app'] = submission + return ready_for_next((response_data, status.OK, {})) + except SaveError as ex: + raise InternalServerError(response_data) from ex + + return stay_on_this_stage((response_data, status.OK, {})) + + +class VerifyUserForm(csrf.CSRFForm): + """Generates form with single checkbox to confirm user information.""" + + verify_user = BooleanField( + 'By checking this box, I verify that my user information is correct.', + [InputRequired('Please confirm your user information')], + ) diff --git a/submit/controllers/ui/tests/__init__.py b/submit/controllers/ui/tests/__init__.py new file mode 100644 index 0000000..7f0ba98 --- /dev/null +++ b/submit/controllers/ui/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for :mod:`submit.controllers`.""" diff --git a/submit/controllers/ui/tests/test_jref.py b/submit/controllers/ui/tests/test_jref.py new file mode 100644 index 0000000..a8ea4fd --- /dev/null +++ b/submit/controllers/ui/tests/test_jref.py @@ -0,0 +1,147 @@ +"""Tests for :mod:`submit.controllers.jref`.""" + +from unittest import TestCase, mock +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound +from wtforms import Form +from http import HTTPStatus as status +import arxiv.submission as events +from submit.controllers.ui import jref + +from pytz import timezone +from datetime import timedelta, datetime +from arxiv_auth import auth, domain + + +def mock_save(*events, submission_id=None): + for event in events: + event.submission_id = submission_id + return mock.MagicMock(submission_id=submission_id), events + + +class TestJREFSubmission(TestCase): + """Test behavior of :func:`.jref` controller.""" + + def setUp(self): + """Create an authenticated session.""" + # Specify the validity period for the session. + start = datetime.now(tz=timezone('US/Eastern')) + end = start + timedelta(seconds=36000) + self.session = domain.Session( + session_id='123-session-abc', + start_time=start, end_time=end, + user=domain.User( + user_id='235678', + email='foo@foo.com', + username='foouser', + name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), + profile=domain.UserProfile( + affiliation="FSU", + rank=3, + country="de", + default_category=domain.Category('astro-ph.GA'), + submission_groups=['grp_physics'] + ) + ), + authorizations=domain.Authorizations( + scopes=[auth.scopes.CREATE_SUBMISSION, + auth.scopes.EDIT_SUBMISSION, + auth.scopes.VIEW_SUBMISSION], + endorsements=[domain.Category('astro-ph.CO'), + domain.Category('astro-ph.GA')] + ) + ) + + @mock.patch(f'{jref.__name__}.alerts') + @mock.patch(f'{jref.__name__}.url_for') + @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_GET_with_unannounced(self, mock_load, mock_url_for, mock_alerts): + """GET request for an unannounced ui-app.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, version=1) + mock_load.return_value = (before, []) + mock_url_for.return_value = "/url/for/ui-app/status" + data, code, headers = jref.jref('GET', MultiDict(), self.session, + submission_id) + self.assertEqual(code, status.SEE_OTHER, "Returns See Other") + self.assertIn('Location', headers, "Returns Location header") + self.assertTrue( + mock_url_for.called_with('ui.submission_status', submission_id=2), + "Gets the URL for the ui-app status page" + ) + self.assertEqual(headers['Location'], "/url/for/ui-app/status", + "Returns the URL for the ui-app status page") + self.assertEqual(mock_alerts.flash_failure.call_count, 1, + "An informative message is shown to the user") + + @mock.patch(f'{jref.__name__}.alerts') + @mock.patch(f'{jref.__name__}.url_for') + @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_POST_with_unannounced(self, mock_load, mock_url_for, mock_alerts): + """POST request for an unannounced ui-app.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, + is_announced=False, + arxiv_id=None, version=1) + mock_load.return_value = (before, []) + mock_url_for.return_value = "/url/for/ui-app/status" + params = MultiDict({'doi': '10.1000/182'}) # Valid. + data, code, headers = jref.jref('POST', params, self.session, + submission_id) + self.assertEqual(code, status.SEE_OTHER, "Returns See Other") + self.assertIn('Location', headers, "Returns Location header") + self.assertTrue( + mock_url_for.called_with('ui.submission_status', submission_id=2), + "Gets the URL for the ui-app status page" + ) + self.assertEqual(headers['Location'], "/url/for/ui-app/status", + "Returns the URL for the ui-app status page") + self.assertEqual(mock_alerts.flash_failure.call_count, 1, + "An informative message is shown to the user") + + @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + def test_GET_with_announced(self, mock_load): + """GET request for a announced ui-app.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, is_announced=True, + arxiv_id='2002.01234', version=1) + mock_load.return_value = (before, []) + params = MultiDict() + data, code, _ = jref.jref('GET', params, self.session, submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + self.assertIn('form', data, "Returns form in response data") + + @mock.patch(f'{jref.__name__}.alerts') + @mock.patch(f'{jref.__name__}.url_for') + @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) + @mock.patch('arxiv.submission.load') + @mock.patch(f'{jref.__name__}.save', mock_save) + def test_POST_with_announced(self, mock_load, mock_url_for, mock_alerts): + """POST request for a announced ui-app.""" + submission_id = 2 + before = mock.MagicMock(submission_id=submission_id, is_announced=True, + arxiv_id='2002.01234', version=1) + mock_load.return_value = (before, []) + mock_url_for.return_value = "/url/for/ui-app/status" + params = MultiDict({'doi': '10.1000/182'}) + _, code, _ = jref.jref('POST', params, self.session, submission_id) + self.assertEqual(code, status.OK, "Returns 200 OK") + + params['confirmed'] = True + data, code, headers = jref.jref('POST', params, self.session, + submission_id) + self.assertEqual(code, status.SEE_OTHER, "Returns See Other") + self.assertIn('Location', headers, "Returns Location header") + self.assertTrue( + mock_url_for.called_with('ui.submission_status', submission_id=2), + "Gets the URL for the ui-app status page" + ) + self.assertEqual(headers['Location'], "/url/for/ui-app/status", + "Returns the URL for the ui-app status page") + self.assertEqual(mock_alerts.flash_success.call_count, 1, + "An informative message is shown to the user") diff --git a/submit/controllers/ui/util.py b/submit/controllers/ui/util.py new file mode 100644 index 0000000..38866a4 --- /dev/null +++ b/submit/controllers/ui/util.py @@ -0,0 +1,173 @@ +"""Helpers for controllers.""" + +from typing import Callable, Any, Dict, Tuple, Optional, List, Union +from http import HTTPStatus as status + +from arxiv_auth.domain import Session +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from flask import url_for, Markup + +from wtforms.widgets import ListWidget, CheckboxInput, Select, \ + html_params +from wtforms import StringField, PasswordField, SelectField, \ + SelectMultipleField, Form, validators, Field +from wtforms.fields.core import UnboundField + +from arxiv.forms import csrf +from http import HTTPStatus as status +from arxiv import taxonomy +from arxiv.submission import InvalidEvent, User, Client, Event, Submission + + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +class OptGroupSelectWidget(Select): + """Select widget with optgroups.""" + + def __call__(self, field: SelectField, **kwargs: Any) -> Markup: + """Render the `select` element with `optgroup`s.""" + kwargs.setdefault('id', field.id) + if self.multiple: + kwargs['multiple'] = True + html = [f'') + return Markup(''.join(html)) + + +class OptGroupSelectField(SelectField): + """A select field with optgroups.""" + + widget = OptGroupSelectWidget() + + def pre_validate(self, form: Form) -> None: + """Don't forget to validate also values from embedded lists.""" + for group_label, items in self.choices: + for value, label in items: + if value == self.data: + return + raise ValueError(self.gettext('Not a valid choice')) + + def _value(self) -> str: + data: str = self.data + return data + + +class OptGroupSelectMultipleField(SelectMultipleField): + """A multiple select field with optgroups.""" + + widget = OptGroupSelectWidget(multiple=True) + + # def pre_validate(self, form: Form) -> None: + # """Don't forget to validate also values from embedded lists.""" + # for group_label, items in self.choices: + # for value, label in items: + # if value == self.data: + # return + # raise ValueError(self.gettext('Not a valid choice')) + + def _value(self) -> List[str]: + data: List[str] = self.data + return data + + +def validate_command(form: Form, event: Event, + submission: Optional[Submission] = None, + field: str = 'events', + message: Optional[str] = None) -> bool: + """ + Validate an uncommitted command and apply the result to form validation. + + Parameters + ---------- + form : :class:`.Form` + command : :class:`.Event` + Command/event to validate. + submission : :class:`.Submission` + The ui-app to which the command applies. + field : str + Name of the field on the form to update with error messages if + validation fails. Default is `events`, accessible at + ``form.errors['events']``. + message : str or None + If provided, the error message to add to the form. If ``None`` + (default) the :class:`.InvalidEvent` message will be used. + + Returns + ------- + bool + + """ + try: + event.validate(submission) + except InvalidEvent as e: + form.errors + # This use of _errors causes a problem in WTForms 2.3.3 + # This fix might be of interest: https://github.com/wtforms/wtforms/pull/584 + if field not in form._errors: + form._errors[field] = [] + if message is None: + message = e.message + form._errors[field].append(message) + + if hasattr(form, field): + field_obj = getattr(form, field) + if not field_obj.errors: + field_obj.errors = [] + field_obj.errors.append(message) + return False + return True + + +class FieldMixin: + """Provide a convenience classmethod for field names.""" + + @classmethod + def fields(cls): + """Convenience accessor for form field names.""" + return [key for key in dir(cls) + if isinstance(getattr(cls, key), UnboundField)] + + +# TODO: currently this does nothing with the client. We will need to add that +# bit once we have a plan for handling client information in this interface. +def user_and_client_from_session(session: Session) \ + -> Tuple[User, Optional[Client]]: + """ + Get ui-app user/client representations from a :class:`.Session`. + + When we're building ui-app-related events, we frequently need a + ui-app-friendly representation of the user or client responsible for + those events. This function generates those event-domain representations + from a :class:`arxiv_auth.domain.Submission` object. + """ + user = User( + session.user.user_id, + email=session.user.email, + forename=getattr(session.user.name, 'forename', None), + surname=getattr(session.user.name, 'surname', None), + suffix=getattr(session.user.name, 'suffix', None), + endorsements=session.authorizations.endorsements + ) + return user, None + + +def add_immediate_alert(context: dict, severity: str, + message: Union[str, dict], title: Optional[str] = None, + dismissable: bool = True, safe: bool = False) -> None: + """Add an alert for immediate display.""" + if safe and isinstance(message, str): + message = Markup(message) + data = {'message': message, 'title': title, 'dismissable': dismissable} + + if 'immediate_alerts' not in context: + context['immediate_alerts'] = [] + context['immediate_alerts'].append((severity, data)) diff --git a/submit/controllers/ui/withdraw.py b/submit/controllers/ui/withdraw.py new file mode 100644 index 0000000..0c35a51 --- /dev/null +++ b/submit/controllers/ui/withdraw.py @@ -0,0 +1,88 @@ +"""Controller for withdrawal requests.""" + +from http import HTTPStatus as status +from typing import Tuple, Dict, Any, Optional + +from arxiv_auth.domain import Session +from flask import url_for, Markup +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import InternalServerError, NotFound, BadRequest +from wtforms.fields import StringField, TextAreaField, Field, BooleanField +from wtforms.validators import InputRequired, ValidationError, optional, \ + DataRequired + +from arxiv.base import logging, alerts +from arxiv.forms import csrf +from arxiv.submission import save, SaveError +from arxiv.submission.domain.event import RequestWithdrawal + +from ...util import load_submission +from .util import FieldMixin, user_and_client_from_session, validate_command + +logger = logging.getLogger(__name__) # pylint: disable=C0103 + +Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 + + +class WithdrawalForm(csrf.CSRFForm, FieldMixin): + """Submit a withdrawal request.""" + + withdrawal_reason = TextAreaField( + 'Reason for withdrawal', + validators=[DataRequired()], + description=f'Limit {RequestWithdrawal.MAX_LENGTH} characters' + ) + confirmed = BooleanField('Confirmed', + false_values=('false', False, 0, '0', '')) + + +def request_withdrawal(method: str, params: MultiDict, session: Session, + submission_id: int, **kwargs) -> Response: + """Request withdrawal of a paper.""" + submitter, client = user_and_client_from_session(session) + logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') + + # Will raise NotFound if there is no such ui-app. + submission, _ = load_submission(submission_id) + + # The ui-app must be announced for this to be a withdrawal request. + if not submission.is_announced: + alerts.flash_failure(Markup( + "Submission must first be announced. See " + "the arXiv help pages" + " for details." + )) + loc = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': loc} + + # The form should be prepopulated based on the current state of the + # ui-app. + if method == 'GET': + params = MultiDict({}) + + params.setdefault("confirmed", False) + form = WithdrawalForm(params) + response_data = { + 'submission_id': submission_id, + 'ui-app': submission, + 'form': form, + } + + cmd = RequestWithdrawal(reason=form.withdrawal_reason.data, + creator=submitter, client=client) + if method == 'POST' and form.validate() \ + and form.confirmed.data \ + and validate_command(form, cmd, submission, 'withdrawal_reason'): + try: + # Save the events created during form validation. + submission, _ = save(cmd, submission_id=submission_id) + # Success! Send user back to the ui-app page. + alerts.flash_success("Withdrawal request submitted.") + status_url = url_for('ui.create_submission') + return {}, status.SEE_OTHER, {'Location': status_url} + except SaveError as ex: + raise InternalServerError(response_data) from ex + else: + response_data['require_confirmation'] = True + + return response_data, status.OK, {} diff --git a/submit/db.sqlite b/submit/db.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..17b518c086fe0fb3f2dbaab6080dc882d43ecd7b GIT binary patch literal 327680 zcmeI53wRsXb>{(Mh!+XMFBz68N+T&U2}=M8K19imY(pSK(jrI-fMh97f((HnITkPj z&kQKib+(0UCrz3r?juc_rfHJ4o3z<>(=^+Ce!ed(XZ1oZmh7Jp>Dv79?HdR~5A;==_A|Hjmfqd4%UZ z9?u#2f0_Qbex@nFSr7ED*Aea(JmX0o`e-kua?!tLGP&qKM*lwgx2=b6nGg>GAOHd& z00JNY0w4eaAOHd&00JQJFCuXEValFJ(Z{_b^l|V&gg(RJ|L2K*CVKzC1Cd{eO!xnC zztrE`SEES$fB*=900@8p2!H?xfWZDDP!0Q)MkwN4*Y)}XqoZY26m)4rEGxB|B5O%S zT^p@8R;p6DqSORQ9(_twmC;mkY&2s^@|l(>zif!{Glm>LBbKXzN@XcBKa$Ce@suYX zY#s>t6(OkYRvpzP%9^OT>)m^GBZY>f3oBLQPLID_;MbH5QI%_=tn*^6URAb4Q9H#h z{CIIwpa?O}tpxq$+U^u=wN|!9Cp+1n)^$}{X&4rp!pw61{578BYpgA3#yo|O4mi+ZAzk$HmvbCgS`Qzd2;X7%H=)Pyr{^` z_e8ZU3DsTnSjl&|$Pc|e~tne#aBw|*Ot|Ujjst6kzWz1GmHGAg4__)EuN~K3=MnzkzB~*rB)P~G0Z?= zg+>C~AYQ1~t6SwTjpyNk5l{4=qQ4RSuITHc!vkL&_)i1hJ@9&p#19C700@8p2!H?x zfB*=900@Az1hOBIv=PT@ttoJ47V(M|zKKp>M!x2N*6BXZ>PqfVHkZ%Kqf`&8d2`hLFeqkTWr_wK&8^?gg9-nZWO+P>w!nZ7f9 zr~3H5yZVCRe+qvg{JHS|2>)jIm%<+pe>nW!@blqk!<*r1_zmG=crN_v@L2eG__okL zhyE(`>Ci8Sek}Cv(9KXSbTyO>jfD<{g2BHF{!#GPgFh8~fADR=&EQI~7@QA26dVg4 z3*Hs<2fh;ceBiTz{~GwEz{dhV6!@ONw+5aHR0FRMECT05_$etH&^Bp&% zbTuJXJwJV?FK$H$dZH$l*F8Tq;5%Z*DXY5YKiI|8bhVn$%97{f?I=-8tP9nu=O^uw z=^em?EPFl{@*OZr@%%)@H`HUR@X>zXU=O{`rzeYZo_}vYEEPRJZa?G}JU`Zc$a?;r z{g7Gme8hf8FMEE}C|jv+BqUk){0Mt`vLVQNqQ36=ueG@A**ssZjL%+xAmF z=lS9G({kSPLyo7M=Lg$QxeJ~Twx4DfJs)U4Wiy`lx1Tc0o*!sEtw>6;xa4_X`>Bxe ze1H4tQqJ?<_S16K^L_0nV)CB$(_+T+z3r#mqUYW1C(`9z?WfFn&-b*S&gVSu4Etg| zPbNqg&v&TNiJtoUmL`?8q*fQoB8&YF<{x&fQf6__JLBp~ zoyEPwncFV&?M_LhPVa(ho^N-?v3D>j_uKCBowRF_WGzt2> z8(l(W)-F%?`-Z%ALDv(SQvIer-=J4+l&cAS{V6*vZf+UjDs_p%{H7gVF5Ixgnc0nY zc&1^8(@Q!F8)JaJt{GuLUr!c_YCF9AWIH@tx5N3O(hjF(JA5f!v%|~zsvTa+TyKZd zPuStbqSOwj*X?j_d959uUA4mtOQIdVSghFL`C{1)&n>Ok;n||l4(H#{4lkD4;oR%( z@P+j2?C?zCwe9fi<90Z6zQu!mG{DQsF@>H1~iRTAV*?g|cU?P% zXRi(h@ARE?#2O2Pd++w8oN?wF;UF83+6B-u;U1S@YYtu$v=|$wY(e8y{kvV2TWg8~ z?pSkKahFS_*2>~e=C@XnceEa8h^ESJXKr4maCPlA=7;6hcpNok)&+%40t4(}McASW z1UbT_%)z)n>O1Zg=ocT@@R}^-`&`fSxv=YbE*o+^&!&T}=j?L8^_*Gqvx=?qdzq%B z5G|%SBfd)AL{w@%BRBO`sDAI-yY6{l72z1@`;@e^3A52LwO>1V8`;KmY_l00ck)1V8`;Kwuvb==FKM zJ|Fw+q2K?f@6BX3-L?-5g9#u20w4eaAOHd&00JNY0w4eaAOHeg39$Qr?EkysK>`SX z00@8p2!H?xfB*=900@8p23Dy|87>dg3=N7ibBR85wZTCk^bZ}?{F^jLB{bvBuro=B!9-A|hxqXH9-}e9ZybE z1JRNw~|4mIyCdVhIld18kz9IWe6aPo_?vaRk@6 zgPw!26Q%(!ry@R8arH4t)WnP+SCU#;5@kIpmNi|_$c8bOUoAZU6W;NuspKTpFnxxA zO}L*VA5}C&&3jNV%r`|Jq(!D@UBM5RwY3@|En*knofmZP#e?$HyE$8r%yY*OZfO7eRzC|+MODG z{7iCs+V$`TFI6x|En?Vm;)J6`(p#i;x>BJQ*K*>9np~}Jl?9#piaI_<#2l@D`S~CF zt?ARr>9YiDJUKPy472zD!vp`|i9S8>57CwAeDpV>-yQv2bToQb^vC!8KnDXs00ck) z1V8`;KmY_l00ck)1ol6HdwplUosX`?+S~b@=pNscx6{#1fMw};9OIAq zCcGU^QF>X9j)x!IA>TP~w*w2Gp+Sd3hMqy+xlp&$fk^Z#o&(u_QEf#dt2YNYcrwqH1&qO;uMEI^veKQmSjxfoDB;24W9C?0s|8 z(R3Zk=vu;Eotm>l0Y<~_jJGjPjK}$z?1l8@g(5#Z>}q#2S4pT=ZSj;tT6T-ql!{h* ziFqt>k=+S&tYphwiQ!;cNms0z_(Cqdus|){wqnwq#nqlWNf`c_nagG_@*|cy%;Nb) zeq?xS_!K`Z567KCJM8Q#X%*M9&Mg*>H!HUXVkb^`pXqbBz?!Nw>RPMSTj4u8HFPV< zXj>!b>~g8ojy7_mXvw-e;?z`EaW;+Ur7ap*?53IQD(u2+DMg)U`VN2W;)&fmPs<#u zzqFq2Y>q{Z(d}kzJD*ZTTy-E~kGFND#8M4Vf?8guyOfeqylv1fw03Y}1BuH_SCM4v zx!e4)ODFeWD^tp4E{nLm`B)?no0#z4yzH>pRY%KeSJXf!%Uu#K>m6hBE~Z=Uw--zu ziAa@Gyj2K+$qx8q*CuucrJJ-%{kTb3oE|w=0kx%EFSkhJMBQF)6MF2x?5`@ zvYvvS&{&BE66?XSR&W`p-B8quGtr(w$IKu1xVvLnw4In^c|y0@w2-jZGi>EW<8ZBR zue0olE^|4b&n^~AZ1GhnrgO_K9bJv!Fvyt7E-%hsx}4=lEJLU@9t`?pOYz-X!!E{b z1?xFA+OTcvVUpc`SbVP~A*)e@Z~*s|0pfYG+6dZ)9w zx+E zKI4qxT&-p9Ae;h5-?1X>;dHyNyBdt{JF%o_0k&<@wJIPQ8O|SjEY*$1 zP7ACt)+&P8+1tG4HAaq`H3tduTzZK?G*@EQQ%@%XR-$434&z*IRuxvn_A+;Gw$ld1 zvGp=>Y2uR&Q7+rSc0gi!uuk1`>rQRyZ^5yieY7JDvR!E;@($0Pp4WPU|0Vc*urKgE zfqVS#_a}Ql+B+4!84VA-V<7Ci8Tl=r&wIf0X%c%$Kbs?g*twJ5<^f|bC2g9!DQg!; zD}70~cD8opNwXQ|7iY3p`D5L39pjf4t&Ogoms-gvGJbCO)@wC8m&L}8xc`~!_Zh3x zH%A;^V<)#<&wh+|@*6wVa2)H&IjcB_xgApoZHwEZlY4M747B?nbF*%ew2NJL&NdZe zM{gYhv}ZMU`E^yQ2{iW6to4Lg)>|XIT^}tgnLBnm9x$dkonD|Sxi@^f4BPE2?hUY)7Bl5yhK080V$oBOk&QjxiK6@ccGifGU;9FxQJFd|95%v$Y2lb;jo(RO! zac^_T?WX1`L@UvWi?P~mwYwEP(#21$bUT~VQ>{eu3aixkGh@e%A?i(o4rj9j-F4pA z$<-`T!_&+M=4jR_%`Q6uONYJ4S|#rs1Z_`pJ5pX=h_;H6Wa@*mP<15mx2)UB)VXpi zHC{hNBRbU{>0~%91@#hh4@b>McB|RWu%lKxCB898os(*`=MYEXr6=f=PHZ`jBl_@e zbvm-_sMV1cfBN2AaTe-yn7QF<1* zt=i79vtBzX-t4(25Syo3Pjsx6xwTl4^pY`vI(QH6TD3dd&idWy@u`@f>Cojk)={gq z^fk1(=PlX01F_@Bz2Di|OgrQp!I-IN>y+G)z&0Z0d|;0$&iT1b+#brNS_jv*qi#>o z8mp_+$z7wRnMYd}RI%hJbH^~2+BT9J8QN9dO5Q#H?zTi|sbU>rwr&UP;rxtS3qz{3 zqBP_(?YeDFw)nP9lyigF-rRMQc67a4mTbyO^^)9Z-z1nT6Ejg2YqYd3xvn5~17UPO zYnZ#G28MLIFT4bKZHrzpukA4dJ2x-UMpRmr_AuGGMv$E~Z1X8Y+MK0!3U6ymLwH4y zX|Ua!>At`?_}}5JoeE#qYt=oj|gtyVew~F~^xK(o)oM1VH<%hhEYi%O(8at8Tp`w3dt3 zuE%;FZuT78W%%Dl-7$Ec*lqaVmTkxIzb!q!b##}Nm6Mx8fmiLOfHTjI+MS8<4StuE zhNA~KYCgVO&5kTPDt4sBpFXn7O2akQIch$(Tg|RaJL-0&#*O#?qrdHm{!{eJ(f=L& zU(x>=eKGn+(cg=HiX!m?0w4eaAOHd&00JNY0w4eaAOHd&@Ny);`GVds`v|d*Ao~cg z4?p|pWgi^-@Uf2`pD!3{?f>8Dq22!v(C+`8(a%JGIr`JlKa2iW^haKf20|wg009sH z0T2KI5C8!X009sH0T9?X1VX-;*Z8K*Fbf&qh8c?Zj(F{_tqihwBbim_5^*gkns(Ixc}cbUIRlw00ck)1V8`;KmY_l z00ck)1io{~z*1esdGv3hAELhl@H5{y*kCCLfB*=900@8p2!H?xfB*=900_J+2n2ncm;FJ_ zFbl9hd>LW^_NOU>EWrMHWPk!JdGR3$eco z*t-9JlPCJ6=wHwoz{jIM9DP3ers#i*{(kg5FAK||1PFit2!H?xfB*=900@8p2!H?x z>>C2?#r!Lxx+WMe{6E@|*TfKeZ~rep^6t<5%yYp3-%<89e@(1Pa`}3I{VKp5ePgyN zm9KC5*;A$}i1c?jWUZH#^q3@SBG>O5^1ep6Ayl5aF8P?y`Com3zBrh^yR|3m8w{l@ z70RyR{r`RAL@)#dKmY_l00ck)1V8`;KmY_l;2Vbk`(D4V;`{&b{r}%ME&@wI00ck) z1V8`;KmY_l00ck)1oi=euX6uC=ZXG$RF2L?ACKOozy9~r^!xwsjDBGs@PG**00JNY z0w4eaAOHd&00JNY0wC};C2)r??oFK@OOB_`CR5WB$<$=H6*ZYmO;FU?@#OSm=uY2B zZ)##PIX*d^OpQ+^$0t%jCN@4vImb>XCn@9f>A>y2Vee@sk)raZl2eoZsPDLUaw<77 zog7c5PM_&DGEGe-C+Tte4CS5RjIzg1Q_b`|G0t-OSV7}cR5lenekM6R-4pel2u+_( zPM@Vb+^bh!p3LF z_zW7K0prteeD)fjobl;1K6^NyH`Igs|2;B>7zls>2!H?xfB*=900@8p2!H?xynG07 zz6ksNzlb-)K7#Bcz&`x!qnCYf?8C=CdVIb}D8h8V?d4+tGywq+009sH0T2KI5C8!X z009sH0TB4w6JYoM*#Cd+xuQf6009sH0T2KI5C8!X009sH0T6gO5y1Zc<XHKwFYzn0T2KI5C8!X009sH0T2KI5CDO%4*~Z3{~>SA6MTE%OM!3d{YdYt zynn~N=*@)=dtUJHtWIx&ed1>i1e(Xk0(GAED7pWX`@sYba72lB~dGh zaz#-!u_nrTsiM>bN!~6pnaO9<#VkL+IFr4~AKP0Y$M~g1{@C`Sj`1Tq>x(~EO9oIzN&+9p`7V7t)s(iu~{} zzqnN77cVa?oZ`bFt+DciSk_DBs-S6m$xsnjC0Wv0@$-wt>})pQE_%#ewou-anAsd# zC0VGobsnFZiZcaGVkMzkZL8-nr{b>D5*Mj0UrGjt2iVn2j2jx%g}SI}C0((+g)ijN z3kzg@SId|4^SN~X8hv;}q{=Z4`ZBDW9F6U1V~WSe(yiFJ$xC#Z0zfHLqEM zMh;{KOH#!sATyWET(sTFfHc3zj|^`OpW=sQ_E8y*cam)J=_2b?tAodO&~T>|Q+8); zJRE;`)E_G(ydI-ZYEM=rT`UO=T``___R!S!4lvQ=5`kFeq_;V2b!%OyOKM4$%Ga3- zX?AE^e9G)Pmlx+RUAB5Ob?9wkc6Qc@x25qTRxR;oa;E~ZlPA4zx@7d*ZGzh#$6UR3 zTOvOa4zXgXQSCnE?4wqdwquTu#a*4ap^2(9v)$LXbx1=_6Q8te=_a^Z71l}{Le-&A zH&Ioo-g+dcD^;nywYO^Ps#FuyTMN7Tn2B#kr!vX7I#r84OVRidOCTKIJQ;|cBdsDl_q#+!HFABbI|s?T++nnsSAr0dkm zM!(&rMDmtOY|GzGoo!Av@Bk35+SQCK&}d`;fB(>Ra{p#B|1RGOHCGS=ekv(IsQP zVDZ-UOWoC&YHuiNWt%*6KS^FyI&}?}N7C5XxUQ(31YBc)u%?P)se4CbrEN&!4Y57O zIJ&f?Sbtv!5#0Ky-!cJOty2m=VLRutct8p@iVl*D$eJ!tiVRy=oGFv zO&9crc1r}BPJ`%H$t}&wtTi;fR9BU zhFTU&n)Fn6Xf2UdMXd?C?GCBdvck;IhFZtMtCdEJ9py$z7dM&Kl}5RHqt%7-b=GXQ zuhFupy)1FBjmVjl4I1@!(Rf|ZN_DBK&}vxF1&bBqLz!<#`g%nbZpc=AyP|CqjV|dl zD-^R=i!5u)gEjlPtkjGV)s7$|H@93SuxzSWS5&#6ZQ~AGaFy1TYS+QQHM_P)Oe$u_9P1bg zUGuCt+*-+OL1=|pF>G};lU>Nt-YSzWWYRNP7IS%-bv`SGQFAUvHFF5yo@i z)!1S?^4sGPQ^2v%?zkrSYBV&R##Oip!c1Zu*jQ-*aGM*BWfdKCR z_W%hZAOHd&00JNY0w4eaAOHd&00JQJ3MYX3|5tbeLVXYb0T2KI5C8!X009sH0T2KI z5ZD6&?Em)w2_hf>0w4eaAOHd&00JNY0w4eaAn*z&fcO7j;SC7&K>!3m00ck)1V8`; zKmY_l00cl_4+L=kzXwPV0Ra#I0T2KI5C8!X009sH0T2LzS2zLe|6kz^2=ze#1V8`; zKmY_l00ck)1V8`;Kwu99u>ao!B#3|j2!H?xfB*=900@8p2!H?xfWRx90Q>#_f!;s# z_#3@{7=7cwmj_Cb--$ff|MUG5;qM5a54|t=w}Ei)GWTxZpLwb?vRC}SckzP~X^{Oc4MopCUPHLUrgIbRK z-L-NE#h9Ed&qu=h=m>B34?+-^jvEa>8zqHc9fm@%~~rq3^AcT%tmAR6INy9vlZ z;#X}p)M<} zR1sIHGV3uOjyLarC=k2$ptqTG`=0FzmTbw6u{wqwVTokA_~8ygXIV~f+#yK1+Hq1P z-aL9P5G#02plsnqgLy9_J({GK(na@*!wLd02==B&HYEM=rT`UO=T```` z0cVFkrBoM1)6X1vFc5q2LGN>y9o=w8{w}#6>C^>xWHvgW9qZ_Rj&9)UUY4z{uITKm zFQNar`j$h9U3!*7s#CwRl3A~^!qipctOvRK)Yqs7`5y?xvNU?1a64stNYhGHsVvIc zjzLs=xQicl$m?`KM=}{{79M}&SsJiOkAvOy(6{P4m3U|mO0bOGHDIan=7BS}oLyDD zq)X-NV#jIh+#d9B7SLTEr&PQdnhwNfiA%gY7wv|kRys|suik@BR_5;dSQ5nO^eq{Y z&z9;!z4Hp>!9D2XETFqiPN{g)Hx-Cwh|$qq80muE(6&$a+5>ygNHel_*QJ#nZ+a#J zu{3Eh)Ljc2T9oTMlsK~oC5)Wibug0Sxc|pi0R%t*1V8`;KmY_l00ck)1V8`;_CEpa z|M!0z!$J@M0T2KI5C8!X009sH0T2KI5WxN)J^%tB00JNY0w4eaAOHd&00JNY0{fo; z_W%38jbR}OfB*=900@8p2!H?xfB*=900?0J4<7&l5C8!X009sH0T2KI5C8!X0D=8a z0Q>*_-^Q>I1V8`;KmY_l00ck)1V8`;KmY{T-~aR61`Z$q0w4eaAOHd&00JNY0w4ea zAOHgUnE>wp_jBvQHV^;-5C8!X009sH0T2KI5C8!XXc55vAD#dLAOHd&00JNY0w4ea zAOHd&00R4;0QUd;zl~ub2!H?xfB*=900@8p2!H?xfB*>K_y6GoAOHd&00JNY0w4ea zAOHd&00JPe{|Vs!fB&~JECc}%009sH0T2KI5C8!X009sH0qpT2Q|G)p+7#4y62!H?xfB*=900@8p2!H?xfB^RY@Bt720T2KI5C8!X z009sH0T2KI5ZM0&u>arxZ43)R00ck)1V8`;KmY_l00ck)1V8}a{|_Gk0T2KI5C8!X z009sH0T2KI5CDPwPXPP>{ols05ClK~1V8`;KmY_l00ck)1V8`;@cuu100ck)1V8`; zKmY_l00ck)1V8`;_CEo3{~rpbJ<%VFdIv5<-roP&@Nn>x!4FXsen0>OKmY_l00cmw zGl9xnAa*w9ZT3oXMckA&OX7wo>m{wRQj;`IQsk0UF`^bT`E0tF<>wb?vRC=eg8b4V zZwT`vt^(rmS%2(Y%yn#x?((=Ri=!b3peu@0AX?$EX z6)2|9FJ!H1!XYD#l;p3Z^O?DHeq?ei&Mz(%`Nhi%3#WKXK;Np1w#fL@RJ@(EA*#&K zcIudsSXY(JExwq&T4bW-szhAckE$r>it2tAEBp*0D$eJ!EK5bu1zW(ygCG{Ov)Me8 zTF%es()nxr#q2eH#4^TE;KEWqJ3qU~Qnumb^Vtj8e0DLDE%3(#^{TYtXfW;AcKYG? zbK}`S?9wIgTb2x{+q0C)f-bHp>Q;%2t}B{Qy|u)nuIAXG#m*v0t#HUHpEZ~X!PQt@ zFk zp3&B@ZH=oCv(<%4O_EDhWsRD+gDoAH?4gu!yg51(h&}$Ww|U7Lo4R{t+YP$P%)17# zV>E(w6)m-kJI2Pe?%vHTwmmT3oXP}ZCDQoX-ZXBkR3&X)taMdm?j`DM6}UI8E$Mjk z%=th}AiZCAYrS1Pw5#H?FF|oviMQ6=B_40a(}CDyr271=Rky~Fu3BGs30hl)-CAKw zGJfe1e@uM1+bq~st5T|KQWp3BotHI;1pyEM0T2KI5C8!X009sH0T2Lz{X~G>|JOW! z?TP+z^arEQL?4f)qv3(y8~EtJ(*q9=+!pzx$S+1d5P3H8hRA5-_Wn=zzrTO8f3ZK_ z@9q0k-v|4i>bu%^rtd)b55hkiepgrvr^83Xk?Ek#~GyWI+ANIe+U-GB@NBzOx&-Z?!_x-(F zy^r@k*n2Pc7u?Tr-@&bL4{!&4|KR(5-!J&y=iBhT*7u0-n9tMmXFb2t^TD3yda6CS zo>M&$?_YU;&-)AB_j=#xEqk+G-Ww#-?4NsU+~CPjP0-hqm(rISf~+@ce7d?OuBgJ@ zRc`QjD{VQytST$QibS^zTgR_+gXdcDOPQs*F4d%`1f8As3vz|eD6+09)k9BkgQKQU zdZ{j|#?ii6vUZQe4Gy1FNSRokH4Y);fEJ3lKbH41IrCUS$ZR_2AJ1!awrD%{|7 zE9PSHVxpyjVaA+NQ|R<{Yp87NGhdvSR~402NVc|QkF41G%q`7H8r@%P8Tps!24q7L zZwv``{@J3b2-RDpD*;w~>Ojw*1Wdc~Wa~*y?H#lJ? z=B&gv4ejvj?DPxi3k|uZ6@>c{^Vz*1$z1Qp}$P_c?Er@B>Jy-39&n!{S6-j1}K@E4` zW87e-Wm6`dsS0#a=4|*D)S0X{bj1dmE}H)206KWt=9XTzyoOg+`DK>3*v>oK&TE-< zxL{*TXGpnPgKjsZ4bkclNAgxo9n2Y$;$~;T>?ZX^ ziQTfKa$M|np*1z}WZ80|yhs&D8`Q@Q+}3!IQM6WPII_SEO@vmNLnQ>cgJT`O$Owv} zrX>s4EO&B_3Xd|!X{9e0q&4Q0G_sT_gCwt=nCAx5tzhXYY_1h+j3?UR87?**s;ni}gwEX~!^I{-7Tr6fJ&lHq~Tp4=U63-O2YAZ^Wx&`Z; zh8HXH?GJIWAts(*$}1}hq0kPWV}?-F@&dKOnkv-j(rJxf5ahK6(bw*JH5WT>q+eJT zRb%Xv%i_Hca@1Jj*he1VVrPxCxg{E$RZ{^96v}kjTNV>%x!7eUusCm; z$#S;{=%Ol%I-gd{>vWl2)*GsL{~0c}WQb=M#ReM#nP_%HsWyx;jYiv+iB!tqG#5)5 zvK=Ol!>3JCa|Kt=wZPsv#l?=Y%(+~yQKdOetVqJ(q*-DvZA20iy011(JvPC`9%ebL zxkQq8SbK1sizN)1*_=RD$s)fX3aZSsO_^nPnpO^vncB|hosN-b{wHn-)#IaFEX~TD z%gi-uf}ChUAk6bJQ51{;Kc3`bIVLn)w3E$NFeuInHPIZ9ZcmuL!6qT0zAlxu!BeJw zvkTVnut2Rs^Efq2;-uM%7ZxtC8L`^-8KdYly@x>aq+k|*_%EitZr7pB;ZLLFCiB^X_!kfJ> zGwTo`3f2sF<_H(NZj_OA3pj?AQ~U**l&Pz>h?pL8^srSsn;ES!g^FfRH2sr;hlflo z>HM6aR*d1w*_Zi4Te8xY41UaEt5u15R$07nkQ=3uMUg8B@}8vDsH+Kj zDl}-$VzJ~D#_Tk9FL!d*A*ZVeu}V&($ZJ(LAgC&v$mlJDY?X!)iq=jZhDLXB_Z@IqU(?lULMuyD7Y+A9RkMcB$-B5wnn0`) zYoeA|Cl}6%nxc^%tx0oHkj*D^ZF2Tb?&P(0u8O23WSM$`qE={qSFuJlqZgP%tu+rF zx`P{{QOvAxJB88PxuF83*6Pgm3^ithUUlK^A)w`;#`1(Wh#b3(J4lX9^3^qmFBvsk zo;(<3%eXQv?`f%(p?{of{6hnVKI?*#sBFm@`j;AzH`lS+P=p&A4Xp@U#6^(LvwtN) zws#O?{oLSVAzE~@lGF5$R6fP$6mk?pZKf^T(=dlH$tC)@7!GpB&$aW+rX6w`Tl&t@yGNTe1dax{Tgsub;`0U+daM9&S|(1 zU89npVl$4lMDemktWYjfV{U2ba#^gJyHm5h+5Z1x?^`|5KaKuk^atn!K#a~s4-9-| z;1dJiKOhY}Ixsrmi+ncnqmeg9ijlEMsQ<{{$Std`hKnN?R}5;P4^uN ze>wa+;g5vh9DX8vK71@32>n&)Q=y*>y(6?5dMGp){BrQq!H)&s6MQ;&EqFS3N8pQr zPX<01cs8&Ym<$XCdixfB*=9z&8Ma;pQ1` z_-sfQwf17LNDusu72!~GnmZmhC5`2vA!o1u_~vOYZl{}zKSRV`_1)c^;*K0Ki=gFR zt0D>t5sQP#@JR%o<7zPCBS4VyWw)tDhLN6WI|<~W<4b!J0r(Pbpj%1dp~ zI)hG^tYubzGsPV^U`R5Nq2?G{$aGxMu{pmbUntrb zXccAdbq1R!jQWfc^SQjFU8kaC+VHb=+tKE$xS?q?KRXMjtqavhODl78MvE)Hd7K-X zvSf10imI>D5scy}hHoBgi{vh}H?B@Sj<)q^pGlco9%%C1&=IqaY{ovRI@&za)-SW{ zSX8%5KHNOa)>Ka>RwN}^Tq@FUA~Z;AW4+7PNZQfnP^%CoP{w)lI%edd zmIX}aQqE`=+GZPWY%Xa}GzYn%hs}DHv&%}I*41p?+p5bxy1B1;FE^AnDxYZH$;GmiPAgc;bEfLW^*%CRp2!qs`m7#Bj*mRV1~#K&NN*^#c3Xk~z`5jf+pT zWfZokH9Lt>lX)U>v>D~%CvCa9QfG2T)2LQi$C?9Nf|P0PVv?Pex~Cc8;sX2kDQ4#e{8>AUJ+9}h`fHv6-b0?SVEbEe{ zQ@PHomgRM+s%UKcWt{W3^gq-Laq**eHrhr|E;&EhN;L%Ni349aflTjJ=T?zG&-5+e)^hw|AATvCZ6BnmJB9?d&hD zO<;#D;Qimk4b6w@bjp*UiOV{$Gmbj#Ej-O&l1?Mh!Kb*vct~!Pt90wLNdM3QuQ>+~ zZL#|%BT3w}lG-;gf&VqUD%ugcg*FRc=W TWcwO0pU%@v-s*DZ2(A5pP)08d literal 0 HcmV?d00001 diff --git a/submit/factory.py b/submit/factory.py new file mode 100644 index 0000000..31ff485 --- /dev/null +++ b/submit/factory.py @@ -0,0 +1,101 @@ +"""Application factory for references service components.""" + +import logging as pylogging +import time +from typing import Any, Optional + +from arxiv_auth.auth.middleware import AuthMiddleware +from typing_extensions import Protocol +from flask import Flask + +from arxiv.base import Base, logging +from arxiv_auth import auth +from arxiv.base.middleware import wrap, request_logs +from arxiv.submission.services import classic, Compiler, Filemanager +from arxiv.submission.domain.uploads import FileErrorLevels +from arxiv.submission import init_app + +from .routes import UI +from . import filters + + +pylogging.getLogger('arxiv.ui-app.services.classic.interpolate') \ + .setLevel(10) +pylogging.getLogger('arxiv.ui-app.domain.event.event').setLevel(10) +logger = logging.getLogger(__name__) + + +def create_ui_web_app(config: Optional[dict]=None) -> Flask: + """Initialize an instance of the search frontend UI web application.""" + app = Flask('submit', static_folder='static', template_folder='templates') + app.url_map.strict_slashes = False + app.config.from_pyfile('config.py') + if config is not None: + app.config.update(config) + Base(app) + auth.Auth(app) + app.register_blueprint(UI) + middleware = [request_logs.ClassicLogsMiddleware, + AuthMiddleware] + wrap(app, middleware) + + # Make sure that we have all of the secrets that we need to run. + if app.config['VAULT_ENABLED']: + app.middlewares['VaultMiddleware'].update_secrets({}) + + for filter_name, filter_func in filters.get_filters(): + app.jinja_env.filters[filter_name] = filter_func + + # Initialize services. + + # Initializes + + # The following stmt initializes + # stream publisher at AWS (DEAD) + # preview PDF service + # legacy DB both normal submissions and a new events table + init_app(app) + + Compiler.init_app(app) + + Filemanager.init_app(app) + + if app.config['WAIT_FOR_SERVICES']: + time.sleep(app.config['WAIT_ON_STARTUP']) + with app.app_context(): + wait_for(Filemanager.current_session(), + timeout=app.config['FILEMANAGER_STATUS_TIMEOUT']) + wait_for(Compiler.current_session(), + timeout=app.config['COMPILER_STATUS_TIMEOUT']) + logger.info('All upstream services are available; ready to start') + + app.jinja_env.globals['FileErrorLevels'] = FileErrorLevels + + return app + + +# This stuff may be worth moving to base; so far it has proven pretty +# ubiquitously helpful, and kind of makes sense in arxiv.integration.service. + +class IAwaitable(Protocol): + """An object that provides an ``is_available`` predicate.""" + + def is_available(self, **kwargs: Any) -> bool: + """Check whether an object (e.g. a service) is available.""" + ... + + +def wait_for(service: IAwaitable, delay: int = 2, **extra: Any) -> None: + """Wait for a service to become available.""" + if hasattr(service, '__name__'): + service_name = service.__name__ # type: ignore + elif hasattr(service, '__class__'): + service_name = service.__class__.__name__ + else: + service_name = str(service) + + logger.info('await %s', service_name) + while not service.is_available(**extra): + logger.info('service %s is not available; try again', service_name) + time.sleep(delay) + logger.info('service %s is available!', service_name) diff --git a/submit/filters/__init__.py b/submit/filters/__init__.py new file mode 100644 index 0000000..22d79be --- /dev/null +++ b/submit/filters/__init__.py @@ -0,0 +1,141 @@ +"""Custom Jinja2 filters.""" + +from typing import List, Tuple, Callable +from datetime import datetime, timedelta +from pytz import UTC +from dataclasses import asdict + +from arxiv import taxonomy +from arxiv.submission.domain.process import ProcessStatus +from arxiv.submission.domain.submission import Compilation +from arxiv.submission.domain.uploads import FileStatus + +from submit.controllers.ui.new.upload import group_files +from submit.util import tidy_filesize + +from .tex_filters import compilation_log_display + +# additions for compilation log markup +import re + + +def timesince(timestamp: datetime, default: str = "just now") -> str: + """Format a :class:`datetime` as a relative duration in plain English.""" + diff = datetime.now(tz=UTC) - timestamp + periods = ( + (diff.days / 365, "year", "years"), + (diff.days / 30, "month", "months"), + (diff.days / 7, "week", "weeks"), + (diff.days, "day", "days"), + (diff.seconds / 3600, "hour", "hours"), + (diff.seconds / 60, "minute", "minutes"), + (diff.seconds, "second", "seconds"), + ) + for period, singular, plural in periods: + if period > 1: + return "%d %s ago" % (period, singular if period == 1 else plural) + return default + + +def duration(delta: timedelta) -> str: + s = "" + for period in ['days', 'hours', 'minutes', 'seconds']: + value = getattr(delta, period, 0) + if value > 0: + s += f"{value} {period}" + if not s: + return "less than a second" + return s + + +def just_updated(status: FileStatus, seconds: int = 2) -> bool: + """ + Filter to determine whether a specific file was just touched. + + Parameters + ---------- + status : :class:`FileStatus` + Represents the state of the uploaded file, as conveyed by the file + management service. + seconds : int + Threshold number of seconds for determining whether a file was just + touched. + + Returns + ------- + bool + + Examples + -------- + + .. code-block:: html + +

+ This item + {% if item|just_updated %}was just updated + {% else %}has been sitting here for a while + {% endif %}. +

+ + """ + now = datetime.now(tz=UTC) + return abs((now - status.modified).seconds) < seconds + + +def get_category_name(category: str) -> str: + """ + Get the display name for a category in the :mod:`base:taxonomy`. + + Parameters + ---------- + category : str + Canonical category ID, e.g. ``astro-ph.HE``. + + Returns + ------- + str + Display name for the category. + + Raises + ------ + KeyError + Raised if the specified category is not found in the active categories. + + """ + return taxonomy.CATEGORIES_ACTIVE[category]['name'] + + +def process_status_display(status: ProcessStatus.Status) -> str: + if status is ProcessStatus.Status.REQUESTED: + return "in progress" + elif status is ProcessStatus.Status.FAILED: + return "failed" + elif status is ProcessStatus.Status.SUCCEEDED: + return "suceeded" + raise ValueError("Unknown status") + + +def compilation_status_display(status: Compilation.Status) -> str: + if status is Compilation.Status.IN_PROGRESS: + return "in progress" + elif status is Compilation.Status.FAILED: + return "failed" + elif status is Compilation.Status.SUCCEEDED: + return "suceeded" + raise ValueError("Unknown status") + + +def get_filters() -> List[Tuple[str, Callable]]: + """Get the filter functions available in this module.""" + return [ + ('group_files', group_files), + ('timesince', timesince), + ('just_updated', just_updated), + ('get_category_name', get_category_name), + ('process_status_display', process_status_display), + ('compilation_status_display', compilation_status_display), + ('duration', duration), + ('tidy_filesize', tidy_filesize), + ('asdict', asdict), + ('compilation_log_display', compilation_log_display) + ] diff --git a/submit/filters/tests/test_tex_filters.py b/submit/filters/tests/test_tex_filters.py new file mode 100644 index 0000000..3ea45fa --- /dev/null +++ b/submit/filters/tests/test_tex_filters.py @@ -0,0 +1,143 @@ +"""Tests for tex autotex log filters.""" + +from unittest import TestCase +import re + +from submit.filters import compilation_log_display + +class Test_TeX_Autotex_Log_Markup(TestCase): + """ + Test compilation_log_display routine directly. + + In these tests I will pass in strings and compare the marked up + response to what we are expecting. + + """ + + def test_general_markup_filters(self) -> None: + """ + Test basic markup filters. + + These filters do not limit application to specific TeX runs. + + """ + def contains_markup(marked_up_string: str, expected_markup: str) -> bool: + """ + Check whether desired markup is contained in the resulting string. + + Parameters + ---------- + marked_up_string : str + String returned from markup routine. + + expected_markup : str + Highlighed snippet we expect to find in the returned string. + + Returns + ------- + True when we fild the expected markup, False otherwise. + + """ + if re.search(expected_markup, marked_up_string, + re.IGNORECASE | re.MULTILINE): + return True + + return False + + # Dummy arguments + test_id = '1234567' + test_status = 'succeeded' + + # Informational TeX run marker + input_string = ("[verbose]: ~~~~~~~~~~~ Running pdflatex for the " + "second time ~~~~~~~~") + + marked_up = compilation_log_display(input_string, test_id, test_status) + + expected_string = (r'\[verbose]: ~~~~~~~~~~~ ' + r'Running pdflatex for the second time ~~~~~~~~' + r'<\/span>') + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Looking for informational TeX run markup.") + + # Successful event markup + input_string = ("[verbose]: Extracting files from archive: 5.tar") + + marked_up = compilation_log_display(input_string, test_id, test_status) + + expected_string = (r'\[verbose]: Extracting ' + r'files from archive:<\/span> 5.tar') + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Looking for successful event markup.") + + # Citation Warning + input_string = ("LaTeX Warning: Citation `GH' on page 1320 undefined " + "on input line 214.") + + marked_up = compilation_log_display(input_string, test_id, test_status) + + expected_string = (r'LaTeX Warning: ' + r'Citation `GH' on page 1320 undefined<\/span> on' + r' input line 214\.') + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Looking for Citation warning.") + + # Danger + input_string = ("! Emergency stop.") + + marked_up = compilation_log_display(input_string, test_id, test_status) + + expected_string = (r'! Emergency stop<\/span>\.') + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Looking for danger markup.") + + # Fatal + input_string = ("[verbose]: Fatal error \n[verbose]: tex 'main.tex' failed.") + + marked_up = compilation_log_display(input_string, test_id, test_status) + + expected_string = (r'\[verbose]: Fatal<\/span> error') + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Looking for fatal markup.") + + # contains HTML markup - from smileyface.svg + input_string = """ + + + Smileybones + Created with Sketch. + + + + """ + + expected_string = """<?xml version="1.0" encoding="UTF-8" standalone="no"?> + <svg width="174px" height="173px" version="1.1"> + <title>Smileybones</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="Smileybones" stroke="none" stroke-width="1"> + </g> + </svg>""" + + expected_string = """<title>Smileybones</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="Smileybones" stroke="none" stroke-width="1"> + </g>""" + + marked_up = compilation_log_display(input_string, test_id, test_status) + + found = contains_markup(marked_up, expected_string) + + self.assertTrue(found, "Checking that XML/HTML markup is escaped properly.") \ No newline at end of file diff --git a/submit/filters/tex_filters.py b/submit/filters/tex_filters.py new file mode 100644 index 0000000..137709a --- /dev/null +++ b/submit/filters/tex_filters.py @@ -0,0 +1,548 @@ +"""Filters for highlighting autotex log files.""" + +import re +import html + +TEX = 'tex' +LATEX = 'latex' +PDFLATEX = 'pdflatex' + +ENABLE_TEX = r'(\~+\sRunning tex.*\s\~+)' +ENABLE_LATEX = r'(\~+\sRunning latex.*\s\~+)' +ENABLE_PDFLATEX = r'(\~+\sRunning pdflatex.*\s\~+)' + +DISABLE_HTEX = r'(\~+\sRunning htex.*\s\~+)' +DISABLE_HLATEX = r'(\~+\sRunning hlatex.*\s\~+)' +DISABLE_HPDFLATEX = r'(\~+\sRunning hpdflatex.*\s\~+)' + +RUN_ORDER = ['last', 'first', 'second', 'third', 'fourth'] + +def initialize_error_summary() -> str: + """Initialize the error_summary string with desired markuop.""" + error_summary = '\nSummary of Critical Errors:\n\n
    \n' + return error_summary + +def finalize_error_summary(error_summary: str) -> str: + error_summary = error_summary + "
\n" + return error_summary + +def compilation_log_display(autotex_log: str, submission_id: int, + compilation_status: str) -> str: + """ + Highlight interesting features in autotex log. + + Parameters + ---------- + autotex_log : str + Complete autotex log containing output from series of TeX runs. + + Returns + ------- + Returns highlighted autotex log. + + """ + # Don't do anything when log not generated + if re.search(r'No log available.', autotex_log): + return autotex_log + + # Create summary information detailing runs and markup key. + + run_summary = ("If you are attempting to compile " + "with a specific engine (PDFLaTeX, LaTeX, \nTeX) please " + "carefully review the appropriate log below.\n\n" + ) + + # Key to highlighting + + key_summary = "" + #( + # "Key: \n" + # "\tSevere warnings/errors.'\n" + # "\tWarnings deemed important'\n" + # "\tGeneral warnings/errors from packages.'\n" + + # "\tWarnings/Errors deemed unimportant. " + # "Example: undefined references in first TeX run.'\n" + # "\tIndicates positive event, does not guarantee overall success\n" + # "\tInformational markup\n" + + # "\n" + # "\tNote: Almost all marked up messages are generated by TeX \n\tengine " + # "or packages. The help or suggested highlights below \n\tmay be add to assist submitter.\n\n" + # "\tReferences to arXiv help pages or other documentation.\n" + # "\tRecommended solution based on " + # "previous experience.\n" + # "\n\n" + # ) + + run_summary = run_summary + ( + f"Summary of TeX runs:\n\n" + ) + + new_log = '' + + last_run_for_engine = {} + + # TODO : THIS LIKELY BECOMES ITS OWN ROUTINE + + # Lets figure out what we have in terms of TeX runs + # + # Pattern is 'Running (engine) for the (run number) time' + # + # ~~~~~~~~~~~ Running hpdflatex for the first time ~~~~~~~~ + # ~~~~~~~~~~~ Running latex for the first time ~~~~~~~~ + run_regex = re.compile(r'\~+\sRunning (.*) for the (.*) time\s\~+', + re.IGNORECASE | re.MULTILINE) + + hits = run_regex.findall(autotex_log) + + enable_markup = [] + disable_markup = [] + + success_last_engine = '' + success_last_run = '' + + for run in hits: + (engine, run) = run + + run_summary = run_summary + f"\tRunning {engine} for {run} time." + '\n' + + # Keep track of finaly run in the event compilation succeeded + success_last_engine = engine + success_last_run = run + + last_run_for_engine[engine] = run + + # Now, when we see a normal TeX run, we will eliminate the hypertex run. + # Since normal run and hypertex run a basically identical this eliminates + # unnecessary cruft. When hypertex run succeed it will be displayed and + # marked up appropriately. + + if engine == PDFLATEX: + disable_markup.append(DISABLE_HPDFLATEX) + enable_markup.append(ENABLE_PDFLATEX) + if engine == LATEX: + disable_markup.append(DISABLE_HLATEX) + enable_markup.append(ENABLE_LATEX) + if engine == TEX: + disable_markup.append(DISABLE_HTEX) + enable_markup.append(ENABLE_TEX) + + run_summary = run_summary + '\n' + + for e, r in last_run_for_engine.items(): + run_summary = run_summary + f"\tLast run for engine {e} is {r}\n" + + # Ignore lines that we know submitters are not interested in or that + # contain little useful value + + skip_markup = [] + + current_engine = '' + current_run = '' + + last_run = False + + # Filters [css class, regex, run spec] + # + # Parameters: + # + # css class: class to use for highlighting matching text + # + # regex: regular expression that sucks up everything you want to highlight + # + # run spec: specifies when to start applying filter + # OR apply to last run. + # + # Possible Values: first, second, third, fourth, last + # + # Examples: + # 'first' - starts applying filter on first run. + # 'last' - applies filter to last run of each particular engine. + # 'third' - applies filter starting on third run. + # + # run spec of 'second' will apply filter on second, third, ... + # run spec of 'last' will apply ONLY on last run for each engine. + # + # Order: Filters are applied in order they appear in list. + # + # If you desire different highlighting for the same string match + # you must make sure the least restrictive filter is after more + # restrictive filter. + # + # Apply: Only one filter will be applied to a line from the log. + # + filters = [ + + # Examples (these highlight random text at beginning of autotex log) + # ['suggestion', r':.*PATH.*', 'second'], # Note ORDER is critical here + # ['help', r':.*PATH.*', 'first'], # otherwise this rule trumps all PATH rules + # ['suggestion', r'Set working directory to.*', ''], + # ['ignore', 'Setting unix time to current time.*', ''], + # ['help','Using source archive.*',''], + # ['info', r'Using directory .* for processing.', ''], + # ['warning', r'Copied file .* into working directory.', ''], + # ['danger', 'nostamp: will not stamp PostScript', ''], + # ['danger', r'TeX/AutoTeX.pm', ''], + # ['fatal', r'override', ''], + + # Help - use to highlight links to help pages (or external references) + # ['help', 'http://arxiv.org/help/.*', ''], + + # Individual filters are ordered by priority, more important highlighting first. + + ['ignore', r"get arXiv to do 4 passes\: Label\(s\) may have changed", ''], + + # Abort [uses 'fatal' class for markup and then disables other markup. + ['abort', r'Fatal fontspec error: "cannot-use-pdftex"', ''], + ['abort', r'The fontspec package requires either XeTeX or LuaTeX.', ''], + ['abort', r'{cannot-use-pdftex}', ''], + + # These should be abort level errors but we are not set up to support + # multiple errors of this type at the moment. + ['fatal', '\*\*\* AutoTeX ABORTING \*\*\*', ''], + ['fatal', '.*AutoTeX returned error: missfont.log present.', ''], + ['fatal', 'dvips: Font .* not found; characters will be left blank.', ''], + ['fatal', '.*missfont.log present.', ''], + + # Fatal + ['fatal', r'Fatal .* error', ''], + ['fatal', 'fatal', ''], + + # Danger + ['danger', r'file (.*) not found', ''], + ['danger', 'failed', ''], + ['danger', 'emergency stop', ''], + ['danger', 'not allowed', ''], + ['danger', 'does not exist', ''], + + # TODO: Built execution priority into regex filter specification to + # TODO: avoid having to worry about order of filters in this list. + # Must run before warning regexes run + ['danger', 'Package rerunfilecheck Warning:.*', 'last'], + ['danger', '.*\(rerunfilecheck\).*', 'last'], + ['danger', 'rerun', 'last'], + + # Warnings + ['warning', r'Citation.*undefined', 'last'], # needs to be 'last' + ['warning', r'Reference.*undefined', 'last'], # needs to be 'last' + ['warning', r'No .* file', ''], + ['warning', 'warning', 'second'], + ['warning', 'unsupported', ''], + ['warning', 'unable', ''], + ['warning', 'ignore', ''], + ['warning', 'undefined', 'second'], + + # Informational + ['info', r'\~+\sRunning.*\s\~+', ''], + ['info', r'(\*\*\* Using TeX Live 2016 \*\*\*)', ''], + + # Success + ['success', r'(Extracting files from archive:)', ''], + ['success', r'Successfully created PDF file:', ''], + ['success', r'\*\* AutoTeX job completed. \*\*', ''], + + # Ignore + # needs to be after 'warning' above that highlight same text + ['ignore', r'Reference.*undefined', 'first'], + ['ignore', r'Citation.*undefined', 'first'], + ['ignore', 'warning', 'first'], + ['ignore', 'undefined', 'first'], + ] + + # Try to create summary containing errors deemed important for user + # to address. + error_summary = '' + + # Keep track of any errors we've already added to error_summary + abort_any_further_markup = False + have_detected_xetex_luatex = False + have_detected_emergency_stop = False + have_detected_missing_file = False + have_detected_missing_font_markup = False + have_detected_rerun_markup = False + + # Collect some state about + + final_run_had_errors = False + final_run_had_warnings = False + + line_by_line = autotex_log.splitlines() + + # Enable markup. Program will turn off markup for extraneous run. + markup_enabled = True + + for line in line_by_line: + + # Escape any HTML contained in the log + line = html.escape(line) + + # Disable markup for TeX runs we do not want to mark up + for regex in disable_markup: + + if re.search(regex, line, re.IGNORECASE): + markup_enabled = False + # new_log = new_log + f"DISABLE MARKUP:{line}\n" + break + + # Enable markiup for runs that user is interested in + for regex in enable_markup: + + if re.search(regex, line, re.IGNORECASE): + markup_enabled = True + # new_log = new_log + f"ENABLE MARKUP:{line}\n" + # key_summary = key_summary + "\tRun: " + re.search(regex, line, re.IGNORECASE).group() + '\n' + found = run_regex.search(line) + if found: + current_engine = found.group(1) + current_run = found.group(2) + # new_log = new_log + f"Set engine:{current_engine} Run:{current_run}\n" + + if current_engine and current_run: + if last_run_for_engine[current_engine] == current_run: + # new_log = new_log + f"LAST RUN:{current_engine} Run:{current_run}\n" + last_run = True + break + + # In the event we are not disabling/enabling markup + if re.search(run_regex, line): + found = run_regex.search(line) + if found: + current_engine = found.group(1) + current_run = found.group(2) + if last_run_for_engine[current_engine] == current_run: + # new_log = new_log + f"LAST RUN:{current_engine} Run:{current_run}\n" + last_run = True + + # Disable markup for TeX runs that we are not interested in. + if not markup_enabled: + continue + + # We are not done with this line until there is a match + done_with_line = False + + for regex in skip_markup: + if re.search(regex, line, re.IGNORECASE): + done_with_line = True + new_log = new_log + f"Skip line {line}\n" + break + + if done_with_line: + continue + + # Ignore, Info, Help, Warning, Danger, Fatal + for level, filter, run in filters: + regex = r'(' + filter + r')' + + # when we encounter fatal error limit highlighting to fatal + # messages + if abort_any_further_markup and level not in ['fatal', 'abort']: + continue + + if not run: + run = 'first' + + # if last_run and run and current_run and re.search('Package rerunfilecheck Warning', line): + # if re.search('Package rerunfilecheck Warning', line): + #new_log = new_log + ( + # f"Settings: RUN:{run}:{RUN_ORDER.index(run)} " + # f" CURRENT:{current_run}:{RUN_ORDER.index(current_run)}:" + # f"Last:{last_run_for_engine[current_engine]} Filter:{filter}" + '\n') + + if run and current_run \ + and ((RUN_ORDER.index(run) > RUN_ORDER.index(current_run) + or (run == 'last' and current_run != last_run_for_engine[current_engine]))): + # if re.search('Package rerunfilecheck Warning', line): + # new_log = new_log + f"NOT RIGHT RUN LEVEL: SKIP:{filter}" + '\n' + continue + + actual_level = level + if level == 'abort': + level = 'fatal' + + if re.search(regex, line, re.IGNORECASE): + line = re.sub(regex, rf'\1', + line, flags=re.IGNORECASE) + + # Try to determine if there are problems with a successful compiliation + if compilation_status == 'succeeded' \ + and current_engine == success_last_engine \ + and current_run == success_last_run: + + if level == 'warning': + final_run_had_warnings = True + if level == 'danger' or level == 'fatal': + final_run_had_errors = True + + # Currently XeTeX/LuaTeX are the only full abort case. + if not abort_any_further_markup and actual_level == 'abort' \ + and (re.search('Fatal fontspec error: "cannot-use-pdftex"', line) + or re.search("The fontspec package requires either XeTeX or LuaTeX.", line) + or re.search("{cannot-use-pdftex}", line)): + + if error_summary == '': + error_summary = initialize_error_summary() + else: + error_summary = error_summary + '\n' + + error_summary = error_summary + ( + "\t
  • At the current time arXiv does not support XeTeX/LuaTeX.\n\n" + '\tIf you believe that your ui-app requires a compilation ' + 'method \n\tunsupported by arXiv, please contact ' + 'help@arxiv.org for ' + '\n\tmore information and provide us with this ' + f'submit/{submission_id} identifier.
  • ') + + have_detected_xetex_luatex = True + abort_any_further_markup = True + + # Hack alert - Cringe - Handle missfont while I'm working on converter. + # TODO: Need to formalize detecting errors that need to be + # TODO: reported in error summary + if not have_detected_missing_font_markup and level == 'fatal' \ + and re.search("missfont.log present", line): + + if error_summary == '': + error_summary = initialize_error_summary() + else: + error_summary = error_summary + '\n' + + error_summary = error_summary + ( + "\t
  • A font required by your paper is not available. " + "You may try to \n\tinclude a non-standard font or " + "substitue an alternative font. \n\tSee Custom Fontmaps. If " + "this is due to a problem with \n\tour system, please " + "contact help@arxiv.org" + " with details \n\tand provide us with this " + f'ui-app identifier: submit/{submission_id}.' + '
  • ') + + have_detected_missing_font_markup = True + + # Hack alert - detect common problem where we need another TeX run + if not have_detected_rerun_markup and level == 'danger' \ + and re.search("rerunfilecheck|rerun", line) \ + and not re.search(r"get arXiv to do 4 passes\: Label\(s\) may have changed", line) \ + and not re.search(r"oberdiek", line): + + if error_summary == '': + error_summary = initialize_error_summary() + else: + error_summary = error_summary + '\n' + + error_summary = error_summary + ( + "\t
  • Analysis of the compilation log indicates " + "your ui-app \n\tmay need an additional TeX run. " + "Please add the following line \n\tto your source in " + "order to force an additional TeX run:\n\n" + "\t\\typeout{get arXiv " + "to do 4 passes: Label(s) may have changed. Rerun}" + "\n\n\tAdd the above line just before \end{document} directive." + "/li>") + + # Significant enough that we should turn on warning + final_run_had_warnings = True + + # Only do this once + have_detected_rerun_markup = True + + # Missing file needs to be kicked up in visibility and displayed in + # compilation summary. + # + # There is an issue with the AutoTeX log where parts of the log + # may be getting truncated. Therefore, for this error, we will + # report the error if it occurs during any run. + # + # We might want to refine the activiation criteria for this + # warning once the issue is resolved with truncated log. + if not have_detected_missing_file and level == 'danger' \ + and re.search('file (.*) not found', line, re.IGNORECASE): + + if error_summary == '': + error_summary = initialize_error_summary() + else: + error_summary = error_summary + '\n' + + error_summary = error_summary + ( + "
  • \tA file required by your ui-app was not found." + f"\n\t{line}\n\tPlease upload any missing files, or " + "correct any file naming issues, and then reprocess" + " your ui-app.
  • ") + + # Don't activate this so we can see bug I created above... + have_detected_missing_file = True + + # Emergency stop tends to hand-in-hand with the file not found error. + # If we havve already reported on the file not found error then + # we won't add yet another warning about emergency stop. + if not have_detected_missing_file and not have_detected_emergency_stop and level == 'danger' \ + and re.search('emergency stop', line, re.IGNORECASE): + + if error_summary == '': + error_summary = initialize_error_summary() + else: + error_summary = error_summary + '\n' + + error_summary = error_summary + ( + "\t
  • We detected an emergency stop during one of the TeX" + " compilation runs. Please review the compilation log" + " to determie whether there is a serious issue with " + "your ui-app source.
  • ") + + have_detected_emergency_stop = True + + # We found a match so we are finished with this line + break + + # Append line to new marked up log + new_log = new_log + line + '\n' + + if error_summary: + error_summary = finalize_error_summary(error_summary) + + # Now that we are done highlighting the autotex log we are able to roughly + # determine/refine the status of a successful compilation. + # Note that all submissions in 'Failed' status have warnings/errors we are not + # sure about. When status is 'Succeeded' we are only concerned with warnings in + # last run. + status_class = 'success' + if compilation_status == 'failed': + status_class = 'fatal' + if have_detected_xetex_luatex: + display_status = "Failed: XeTeX/LuaTeX are not supported at current time." + elif have_detected_missing_file: + display_status = "Failed: File not found." + else: + display_status = "Failed" + elif compilation_status == 'succeeded': + if final_run_had_errors and not final_run_had_warnings: + display_status = ("Succeeded with possible errors. " + "\n\t\tBe sure to carefully inspect log (see below).") + status_class = 'danger' + elif not final_run_had_errors and final_run_had_warnings: + display_status = ("Succeeded with warnings. We recommend that you " + "\n\t\tinspect the log (see below).") + status_class = 'warning' + elif final_run_had_errors and final_run_had_warnings: + display_status = ("Succeeded with (possibly significant) errors and " + "warnings. \n\t\tPlease be sure to carefully inspect " + "log (see below).") + status_class = 'danger' + else: + display_status = f"Succeeded!" + status_class = 'success' + else: + status_class = 'warning' + display_status = "Succeeded with warnings" + + status_line = f"\nProcessing Status: {display_status}\n\n" + + # Put together a nice report, list TeX runs, markup info, and marked up log. + # In future we can add 'Recommendation' section or collect critical errors. + new_log = run_summary + status_line + error_summary + key_summary \ + + '\n\nMarked Up Log:\n\n' + new_log + + return new_log diff --git a/submit/integration/README.md b/submit/integration/README.md new file mode 100644 index 0000000..ee4ce6a --- /dev/null +++ b/submit/integration/README.md @@ -0,0 +1,19 @@ +Integration tests for submission system +============================ + +The test in test_integration runs through all the submission steps in +a basic manner. + +Running the integration test +============================ + +``` bash +export INTEGRATION_JWT=eyJ0ex... +export INTEGRATION_URL='http://localhost:8000' +pipenv run python -m submit.integration.test_integration +``` + +TODO: Docker compose for Integration + +TODO: Automate integration test on travis or something similar + diff --git a/src/arxiv/submission/tests/api/__init__.py b/submit/integration/__init__.py similarity index 100% rename from src/arxiv/submission/tests/api/__init__.py rename to submit/integration/__init__.py diff --git a/submit/integration/test_integration.py b/submit/integration/test_integration.py new file mode 100644 index 0000000..48c0298 --- /dev/null +++ b/submit/integration/test_integration.py @@ -0,0 +1,322 @@ +"""Tests for the ui-app system integration. + +This differs from the test_workflow in that this tests the ui-app +system as an integrated whole from the outside via HTTP requests. This +contacts a ui-app system at a URL via HTTP. test_workflow.py +creates the flask app and interacts with that. + +WARNING: This test is written in a very stateful manner. So the tests must be run +in order. +""" + +import logging +import os +import tempfile +import unittest +from pathlib import Path +import pprint +import requests +import time + +from requests_toolbelt.multipart.encoder import MultipartEncoder + +from http import HTTPStatus as status +from submit.tests.csrf_util import parse_csrf_token + + +logging.basicConfig() +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + +@unittest.skipUnless(os.environ.get('INTEGRATION_TEST', False), + 'Only running during integration test') +class TestSubmissionIntegration(unittest.TestCase): + """Tests ui-app system.""" + @classmethod + def setUp(self): + self.token = os.environ.get('INTEGRATION_JWT') + self.url = os.environ.get('INTEGRATION_URL', 'http://localhost:5000') + + self.session = requests.Session() + self.session.headers.update({'Authorization': self.token}) + + self.page_test_names = [ + "unloggedin_page", + "home_page", + "create_submission", + "verify_user_page", + "authorship_page", + "license_page", + "policy_page", + "primary_page", + "cross_page", + "upload_page", + "process_page", + "metadata_page", + "optional_metadata_page", + "final_preview_page", + "confirmation" + ] + + self.next_page = None + self.process_page_timeout = 120 # sec + + + def check_response(self, res): + self.assertEqual(res.status_code, status.SEE_OTHER, f"Should get SEE_OTHER but was {res.status_code}") + self.assertIn('Location', res.headers) + self.next_page = res.headers['Location'] + + + def unloggedin_page(self): + res = requests.get(self.url, allow_redirects=False) #doesn't use session + self.assertNotEqual(res.status_code, 200, + "page without Authorization must not return a 200") + + + def home_page(self): + res = self.session.get(self.url, + allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['content-type'], + 'text/html; charset=utf-8') + self.csrf = parse_csrf_token(res) + self.assertIn('Welcome', res.text) + + def create_submission(self): + res = self.session.get(self.url, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Welcome', res.text) + + res = self.session.post(self.url + "/", + data={'new': 'new', 'csrf_token': parse_csrf_token(res)}, + allow_redirects=False) + self.assertTrue(status.SEE_OTHER, f"Should get SEE_OTHER but was {res.status_code}") + + self.check_response(res) + + def verify_user_page(self): + self.assertIn('verify_user', self.next_page, + "next page should be to verify_user") + + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('By checking this box, I verify that my user information is', res.text) + + # here we're reusing next_page, that's not great maybe find it in the html + res = self.session.post(self.next_page, data={'verify_user': 'true', + 'action': 'next', + 'csrf_token': parse_csrf_token(res)}, + + allow_redirects=False) + self.check_response(res) + + def authorship_page(self): + self.assertIn('authorship', self.next_page, "next page should be to authorship") + res = self.session.get(self.next_page) + self.assertEqual(res.status_code, 200) + self.assertIn('I am an author of this paper', res.text) + res = self.session.post(self.next_page, + data={'authorship': 'y', + 'action': 'next', + 'csrf_token': parse_csrf_token(res)}, + + allow_redirects=False) + self.check_response(res) + + def license_page(self): + self.assertIn('license', self.next_page, "next page should be to license") + res = self.session.get(self.next_page) + self.assertEqual(res.status_code, 200) + self.assertIn('Select a License', res.text) + res = self.session.post(self.next_page, + data={'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', + 'action': 'next', + 'csrf_token': parse_csrf_token(res)}, + + allow_redirects=False) + self.check_response(res) + + def policy_page(self): + self.assertIn('policy', self.next_page, "URL should be to policy") + res = self.session.get(self.next_page) + self.assertEqual(res.status_code, 200) + self.assertIn('By checking this box, I agree to the policies', res.text) + res = self.session.post(self.next_page, + data={'policy': 'y', + 'action': 'next', + 'csrf_token': parse_csrf_token(res)}, + + allow_redirects=False) + self.check_response(res) + + def primary_page(self): + self.assertIn('classification', self.next_page, "URL should be to primary classification") + res = self.session.get(self.next_page) + self.assertEqual(res.status_code, 200) + self.assertIn('Choose a Primary Classification', res.text) + res = self.session.post(self.next_page, + data={'category': 'hep-ph', + 'action': 'next', + 'csrf_token': parse_csrf_token(res)}, + + allow_redirects=False) + self.check_response(res) + + + def cross_page(self): + self.assertIn('cross', self.next_page, "URL should be to cross lists") + res = self.session.get(self.next_page, ) + self.assertEqual(res.status_code, 200) + self.assertIn('Choose Cross-List Classifications', res.text) + res = self.session.post(self.next_page, + data={'category': 'hep-ex', + 'csrf_token': parse_csrf_token(res)}) + self.assertEqual(res.status_code, 200) + + res = self.session.post(self.next_page, + data={'category': 'astro-ph.CO', + 'csrf_token': parse_csrf_token(res)}) + self.assertEqual(res.status_code, 200) + + # cross page is a little different in that you post the crosses and then + # do the next. + res = self.session.post(self.next_page, + data={'action':'next', + 'csrf_token': parse_csrf_token(res)}, + allow_redirects=False) + self.check_response(res) + + + def upload_page(self): + self.assertIn('upload', self.next_page, "URL should be to upload files") + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Upload Files', res.text) + + # Upload a file + upload_path = Path(os.path.abspath(__file__)).parent / 'upload2.tar.gz' + with open(upload_path, 'rb') as upload_file: + multipart = MultipartEncoder(fields={ + 'file': ('upload2.tar.gz', upload_file, 'application/gzip'), + 'csrf_token' : parse_csrf_token(res), + }) + + res = self.session.post(self.next_page, + data=multipart, + headers={'Content-Type': multipart.content_type}, + allow_redirects=False) + + self.assertEqual(res.status_code, 200) + self.assertIn('gtart_a.cls', res.text, "gtart_a.cls from upload2.tar.gz should be in page text") + + # go to next stage + res = self.session.post(self.next_page, # should still be file upload page + data={'action':'next', 'csrf_token': parse_csrf_token(res)}, + allow_redirects=False) + self.check_response(res) + + def process_page(self): + self.assertIn('process', self.next_page, "URL should be to process step") + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Process Files', res.text) + + #request TeX processing + res = self.session.post(self.next_page, data={'csrf_token': parse_csrf_token(res)}, + allow_redirects=False) + self.assertEqual(res.status_code, 200) + + #wait for TeX processing + success, timeout, start = False, False, time.time() + while not success and not time.time() > start + self.process_page_timeout: + res = self.session.get(self.next_page, + allow_redirects=False) + success = 'TeXLive Compiler Summary' in res.text + if success: + break + time.sleep(1) + + self.assertTrue(success, + 'Failed to process and get tex compiler summary after {self.process_page_timeout} sec.') + + #goto next page + res = self.session.post(self.next_page, # should still be process page + data={'action':'next', 'csrf_token': parse_csrf_token(res)}, + allow_redirects=False) + self.check_response(res) + + def metadata_page(self): + self.assertIn('metadata', self.next_page, 'URL should be for metadata page') + self.assertNotIn('optional', self.next_page,'URL should NOT be for optional metadata') + + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Edit Metadata', res.text) + + res = self.session.post(self.next_page, + data= { + 'csrf_token': parse_csrf_token(res), + 'title': 'Test title', + 'authors_display': 'Some authors or other', + 'abstract': 'THis is the abstract and we know that it needs to be at least some number of characters.', + 'comments': 'comments are optional.', + 'action': 'next', + }, + allow_redirects=False) + self.check_response(res) + + + def optional_metadata_page(self): + self.assertIn('optional', self.next_page, 'URL should be for metadata page') + + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Optional Metadata', res.text) + + res = self.session.post(self.next_page, + data = { + 'csrf_token': parse_csrf_token(res), + 'doi': '10.1016/S0550-3213(01)00405-9', + 'journal_ref': 'Nucl.Phys.Proc.Suppl. 109 (2002) 3-9', + 'report_num': 'SU-4240-720; LAUR-01-2140', + 'acm_class': 'f.2.2', + 'msc_class': '14j650', + 'action': 'next'}, + allow_redirects=False) + self.check_response(res) + + + def final_preview_page(self): + self.assertIn('final_preview', self.next_page, 'URL should be for final preview page') + + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('Review and Approve Your Submission', res.text) + + res = self.session.post(self.next_page, + data= { + 'csrf_token': parse_csrf_token(res), + 'proceed': 'y', + 'action': 'next', + }, + allow_redirects=False) + self.check_response(res) + + + def confirmation(self): + self.assertIn('confirm', self.next_page, 'URL should be for confirmation page') + + res = self.session.get(self.next_page, allow_redirects=False) + self.assertEqual(res.status_code, 200) + self.assertIn('success', res.text) + + + def test_submission_system_basic(self): + """Create, upload files, process TeX and submit a ui-app.""" + for page_test in [getattr(self, methname) for methname in self.page_test_names]: + page_test() + + +if __name__ == '__main__': + unittest.main() diff --git a/submit/integration/upload2.tar.gz b/submit/integration/upload2.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b665ee41d50655de94bb2f6c267ded696e752599 GIT binary patch literal 27805 zcmV)cK&ZbTiwFo+{i;a<128!-GcGVOI4~}BVR8WMTzhxhwvx}kt51Ow*QZV!E0P@B zsrR}YztUHnq_Ml#yS1_9#mdwFIZ@i`ZUm)GJ( ztG;;6Aw3f1hv7HhJbyMEJsS)^BJ6d-{j-ou`C&Lr)yKkgM#)&u0nMZ!1D}Y3M^a6r zr|={1Be!f7eh9_qIQfVeRAJd;%sCfc2+F;L=d0jvLKedTuw>QGVOboM+iaXA5sPGs z>gk~Bf3sg!KQAm)&WSn|&_Cyimq(zFVjCIxV(cgprJNTI^rjRZc3%h(c z%M^EB5=DS1965%%c^;~=@S?2jRqA;KY+msw2=9xx*Tb?%Ra6%3;;Si7MWYn{k4sjt zKka_YAS2rVA3(J=KG=XjhMEBJ$tHNOQGvIPz-_m{-2mTgfr~Am4qZ((*VO=(l)1~k zLfU8Uvv_TFb6wvuA?5)k8c`OR?j(wu+ZFXc@+{!jFPGcgYmSPOFr9K&sKP6}y7t#imUyKGP1SBmY{EMllO-5nCN^8rTmM+PNi zigx~&SCwsdvacZ(`xoD`KYauLhJ$a}M8r^&7s-3J+bA{^WY%wi5UAbKB-q|`!ed;K^#mpZ;B zF1dFiVO$#~M>8#r@Q)9Mr48#^u_G(ivtswGSl^0$Zp8{KcHfE}Td@aL?8J&av|^`L z>ZN#a>vki52_GicM|U!+{n1*^0H> zZ)U~X?N?f{cKcOUtlfTdE7oqmGb`3^zjs!w-G1j*tlfU^tysJL{>_TD+wX}D``q4d zr&g@J-_ER9d%vAqvG#tuuww1~_R5O2_uF??ti9j9w_@%6_Jb8`@3$YVSbM+yWX0P3 z&TA{y?sqP&Si9eOW5wG2&RZ+i?swkVu={qu^RpFex8L_xtlfUEtXRAKUR$wt`~Ag= zwcGE%Td{Wg{j(Kox8EBp)^5MQTCsNf{fiZAwco;V^AMKT;ntls%d%%a2>P9EQiEH1uqqL*rFs{W#MFtfim5|I;3U{NY0?Bj(rY~EvC zEiM@kr0DO9x!&#?ZY0werBx$eo9#&um(}X=uU}h{$7W+9!Ughpc@tBq;X4i+<7G7v z&EvtquDNp&sxFeDo#c8dI!Q)$lGi+P{Vtk4JIzhdfq!ntzw5&9+woVyq*GZ3c9LIG z8H7pPLDXTJ@1|XXz33p2T?F6Q3EoCx($$jXQAUSGhy4R{R#fbl)k&O8@Nm$>BQt#j zlS?tq!}|DThfbAVe|ZthKulshN@5jcWi?!qNl`WSv})|Wh|F9wp5WXT^0^a?r$#^WHb@shXB-&%)6-Iq<#!1l z>@!dJ(53vSstKOcBm{*yQYe_G2FH530-XVOKZu;E8XOPBoYe}w0EN;ytq3J-?309t z& z3FMwm)J5#q$Karch=-w*@(fkV2B~spn8IUz5I4>x+lM^Te-F^1KI(R$|8kW>wopzi z=J1u!i8OkVI4lX&%Iab7;}N<>i#r^}q*K$KPaQJrmWh+*K=q&k?)#7{QPL6w$%4sL zAsI-_(h$V#KX;}QJUbND=lX6@I!fX5-(i3ycOu*CV{@p_so%aF!d_L2`&*T>cUXd= zs^k8M2ch9c;-WmHqWxM!d%&`Wf`E9Wzql~SvkmDHsHw{u)b58~b6mg6k~ERRKXN|j z9O+Y?kvUVCjKUW7z3@aZtFW1Q^0^X@n+bl~0$&La#&mcaz~b(DIdLSTAnHq{_!*H0Z|lQ&mVyLwbNwo~Lx# z<0i>%#Isu&=aCBo&OcUJE}piE7Fk{rCK*_0GY=mr|JV&HSfIpG7On%9vS6g;OIaz1 zyOagJzYOxnFi!4**iY^jVn8llHLF=5?v7st>h$|+_2Tes9?WRj;#|I`TT65xprGoe z)D7G)NSK%$H3$my?EUYqpACkC(ca#3&>oN%;EYC}j`l`_(P8bV36I{>>W{H|$?|~# zEY`=xRH^jn*)yW*C)wl~cjYs1wc9X8mD2Z@M@}kY9;$n`I~)uQt7;0MW3PS+{nc%&{I%#^_h)@PLaA{aE59 zw!Rd0?~R=Ec|%f@bNL>-!d!amtMI-n&600E|I6uXsG_VMe|&Yhczg*KK~wvG$%9ct zNC;TJh8cEV4zs!#Yh$UN@R}NCoO|YFxi9*R{h+W_5=KBCD;PjHNBMzNK|INEMiuca z$P!xlN@OGxuq>`v07H`Bvmz9@{n9+pUL?&J!W_0%}NHevD!GO$dt_~Tn$`|ol!4>o; zkXXtAfqm`0BpKk>$Yg^WUbL_<7taa1OS0Qag(gy>(bbHGK$J_xrnGS(C{EKbF@R}M z^o4VCb7Rbk;9!z4$b9UeDY~K%>=&`7v9N|THu5QP>lGTwwqUX~+!_^*n5RI%$Iy1Y zjFY;o0bC5tVrx{*7FTF5;4OhR^H~Ee(bC8ER-(`3afPOq-6uJ%*s6FESX*NJi7TK* zPZ=ytKp`@K-2=~PcY=ckXfk#!ZfHiJF6}KhZV?GkTvVy;hp2IqD5zDGl){7sgl??V z-!>M(1Qu&$@k;+%5Q@Wk9!#EoEak8;fsMjD{I-*fLJe^VtP14SBQ|JRH(t%k6Sj_+|50sq)=%h+@qSj(o z17K|%*KNAwdO^`8mj-VD1TO+^foJz%6Lqo+w*h87WDTO_!aK@W7q2LlxMwE*S5>ML zwH~t?p*I78HvM;vhZWcjft$fLLTv;RnY!0NN_Bx&LZmlM!T?9gRh6=-RS6L33csz^ z6Mq69LXPxok?uxr0$YdqoC~Qt@#iYl0onkv7O@(zx(1r=jlrES(%T)#O+Xu9) zYl6Ae%))P~lpVAVu$lw7K-grK#e4m`ZAYvrUs)Wj=DxOW*aBA&*@0ODh}LmfQ!eH_ zN^u=>VG>=KHHftU9<2kcEa%}Q$pV_!*5$@ z$OEYOjDD=;t4g_)d6eo=b+SlzBiF&eS9)PCiQ(BI-G$r)v;oF9{_LzycH%Ywt%pdJ z{+Um9km{@29U$*5f|TACeeXe2UuCIFO;Y`Z|h_SZXE=6bJ?t7)_CZ| zZiw3iwE<==1cum5#PtBL(%r~SU~4h6wYA;xEDmCrivDa89hj8>eMa%7cd})5Z`syoo5&QU#O8b}}OEk$Tmw{Kkb#tWiCPJ|^3-Bgh;DH*RthzvUZgnFWIUEYx%(VX)}yHB%uLR3)ZaIGz$6xTbg(RyPt+Zk)Ab?K zOyH|AJY;l()vM@^5Bdk@rfRRhSKaC8qzTi8xx5I2XfC&#u z@t&Zf#r~@TJ-F-Q{Sysfw*0D%3ri(D-h=s3C=YZ#><{)!B4^;eVwR_jGsS(T@5{X@ zzJSy^io6fS;;?teNkNl%s7P`aoxr~0e@e4zVj`C<66 zs4j4JUknCB4~iON6asNEv`(Dpi@Y`q*o$ZuO|rUA&B)Ns;=0)geWsiVy5v%K3BKkA zJOy4`YJ{xZ1DFCHLLHHYj3z*%)yY#Jw7&3`ni;J!e4m17QM;iSEA}aeZSOb`{!{2_ zG!Ez{^mkR{gMJ$^OQ2Z{FXd}$a12V&;WCuVkjDr`MD_OHV%rQd?(i_+(sbk#-P;QQ zh~4aB6q#5w#oj>VJZ=e1ESi!%lzY*xKB2Yf?}a|$*DqnxtACa5N#{?SZ9|?~cpMzx zUBA=N$^fg5!MuW~VRY^FN5*Gr?@2zBl4a5DPw9FQUo$JwqVUJK4tyyu(l-QPWtgU` z;DT+9^dl$-OfsqsaA#}piM|e8mauu&@!t^>-?jP!#jK7T{`ZIoW^=CJZ(H(8M?J@4 z1Iv>YG2lK=RnzETK@Dq$=0#4^#1`*NHV_5Tp!50QP!e17ksKe!AfzyL{(Ny>IG=^D z-_q3YW#PM-%uz`SQ&KgpQGiMo%XrI!)HF$< zO1#IaxH&K3Aq90|*hpzvGW`_iYWk&w=>g_5@PrZfvV_%lY4USM_h*GO(@P^r(7o|A z%Rw(pFERRCU>m)EF;~hnDxx(76&TPK#-UKel};_gN@PHJ4W?zexJ;Ly3zTPnzih3W zFg}D)3=%VKK$i#2-c824)G@Me;@qGsbQoswh352ynR<85wHu-fO@=W*h8JC}&TvgL zL36QQn=S-FgTkpq+p_FG(RX%ObskqyBNz~1zl}utW`satLJ}&or_fT%DnmY;guMhq z@+i8gzaQle#)ZT%Jc78M7q{6NW|}wBQ`v8Gh;eNO8Mqc+3I@ct>^(v6s|&rpU^{@M z=O@c-LR?pkgV03bG}*iX8e9x5k>Jsbqpb}~TO?<7wpK~~E;|kxY36Gcz~Zm68hR+4 zm%$jr#xS_ygQG(asl<`cwI*HmbZALt8Fc=8nD)(HKtCt{(q}MNQRssAOnlnaIIKxLC-gDQn2L$b6vTTJB+K zxKPX>$|dn{Kbqq6}E4H>Bt_s@tG}T@o0ZXqpL`<&upw zjZlY5)+VSQDU=AeK8{hm4&Bson7C4(w=7H(31egWscagViN|akna-p(E*Y6Ac~}9} zJ<~h$I@S>;4>G@D9X!hzg;!RMqKCRkzb%^d_eSe50IX?%3lnIym3ZlfQd0k2hGm1P z;z_Fqmz${Jc@^EMbE_cF2E8Sew5W}Matj@-hDntiVJI4Qr-jv|P1SC~Q)Xu~4i;L> zv#^Rr(=o^x)(Ey$DHh7HYc^MHTbDqtH3^Bb{6o~(h^IBmfE=U16`E^eo6=TFvuXQFsa8F!IPAHTOH;A|zG))X7Q~XxMz4pMVc~MJDiGF)Q2ZsY~kJ%d7 zXkpD`!LD55-omw7S2d6RT8^fQVE%wbE6#>N6bV1Ti19nTOBx>N>9Dp8;)lSBs9@D{ zqRxT+9PShrc>O{`74s+aG==F}`2UKl_)6yu8VvYYaQijMna5*_zUGxWdFBRS@1SSB zpTMK;`ZO@n@rW?sqk8RN6%Hz@Ex5S6IvWiJga2&r%lq3nt_1h*`V_tEPDqR)n1?Lc zO6ajH+Z45S;+5pdVK~|b*Z?~O0U8ZjKrq?DRTYD2cjt`_cWogJKvGW+X{onr>c%Zcm*xjpXc6&7kYrEU$)c#&tKpsK>){4D` zi|MuF<@}FIH1Q7+&uI|%Up}WsC~$(9jfZzz&Tpy(>jBFpL}rkl5L^T5o__xu%WT#q zrY~dVjYS<($ayx-Y}XJlX0d_YwG#MGcv{F(Ag7k@>NPG2RhaDZZP5rgY)>&o+VFZh zdpjvF$Ebd^4;JjFdkG3?*NZ(Tbg`|bi}~!5?9~uu=9|k=-WGP9!3->{F+7T#iBpOZ zL}SW4U4TRIcrE4-j)-~#kONVKPHh@Gqtg&OR+!1Ej9!*H)1Rqc?;U|9{ zOyWmXUEh_2`Qo2kLPU!mtX$amnM2ML@{yo3O>jAWOto?mWU3hk8%O{_JQAUl_$kLm z#*Z=J#`5rxvvF7^f4#LQnj%G$*|@P2?WYH(kh>|^ztQGGI82W3$JqpeDqU~9bp2SZ zo*(nEhHY7~9<*GQMYC6Us9GwRike5i=yw2j>;MMo$UT7AJbmC0)^y9Pn4GvgQ|&;T z9k${ps*S`ZN+TVs7&OBmLEp;73^p6#Qvn7JKa>}BIO19df5DxfRghEi8s zg?fq2?8T?t3)WBYgkJ$)E0dW0hF%g*}7*Y6g|_sL1YrRm{hw=?}a@ zbQ5ZCAn?+N$rlG^WPFdI>tUePq=4T{7JPD)TrrKsBm@S_jD$A3aZWIhS>lH2tHeD& zlwQFUqRA1f0qP+Me{5Ff$vh6;+8u8AL2bchc=dtp?s7Klfn@#f!`WqdmJ}uxDMFb} z{hko-uub>SKkl$1W4->RdjCzP9#iL=m}fOQRccH$ef+a*pX^@0im9yaU%3x#e31K& zOJp1Ubq4fuLO@>w(ss5$M`|JTU3;J)NLGt1?}P8oIayyGoZQ^y<;+q7%Z&Gb-OmjD z{JQ_&`~N++bk9bI*>N0avkxwqXEE6N*|XGoMzA-BM3c=>?qtkSXAYOM>L_euuOY?!rxr&UF&fW#KY|ehraUuRf(S;H zhlpMX!w_3uTHY8A+nu_H0f38rVPFmS>Koa|Ct1kTY;fkL#YhYe4I0T`E_;?mKpfBP z!e;&X?=PNY@{JBByDzBm_Cf-ug_1oWpb1ns8#u!47bdAHoT$!{U|{ z6?%4Ab_mObg@LAGZXe^t5LeNjT`oJcIX>!B?&-bb@7_E4Ui8BF#-~6uh8Ejl794B{ zxm3s#!xA1V$y$+R0rENE9G3>5TrNHh5&@v$fV7k#A1&-&^L+^3i@XCi>%a#D9A}^p z8h-$(%EuNDli!G?&Bff}EZZ2|X$`BNKegOXh4@EQPj>HuXD$;0o)7nh>!xjSlYoNBLDehH87DY;Tw?i#5F@28qk+ChNbw^uK4HaB>@1 zBeiuamCfvrC$oX2xP1p0& zrL4>V6fm1y@_T*$m!*CIy@W@Eyg;^Y{ucN|@H26W@Pm*E{A3eY%iZl?-_Q1F zBDtj+_;xWHZhSI;&s2vSr_?;RD+FqXf-Wd!EVEV5rjWwGt1mx=78dG1?ehm#g3*_? z`duf%(t40j!56Puqa=A_Mc`_B@=;Hu$CBEUOCZX!hFk#cSCJ~6sEUqqG%M)yg1a{I zuRAwj5I7DdBOgM5CNN?EFWIO8flc5Y6!+#g#8==0aJy`6-E6+2ec}atLvozRK{)?+ z+2_n1hb+x*xceyTA^y4!S|yH!g!0Nt<;po4xd}UIdo>5uq*|;9ytyWc7>_|^CzyP& zs?kwTrQQQ4NIUz}@`k-6ffd^*-#cc6s_|tthqM}!5fuo)>~@QLVCWkNvexWY3NDc2 z!E`pMZmLBsT-004c-VhiU6%NXpSC={Di-+6{wocej|P^rvHzC_@D-8+Qbt91W;qy( zfWX%h-0Nv+oJM#sSpP4_Uf(!0vyVhxa~XL@@_;^gzH zMys?9Y4vkSj<*-Dv`NxmZCttNVu`LeoZb6ntbo})EbtgOi zDEs};-U0i@(NWO+6cUBvU|rJUjTbkT2|#TG1~%b$(40dKo6GFO-qErBU(m%W1c+?%2|7)<`#KdJuT(aEDnC$9dV{U6l-`|#-DyZV3sh(FRX`qwEjB#tgILI zwiFwSNURKZzdXVAjmL zMRCi?48RUfSWycm*A42WF zPWJHO@q-5sKYWxy9?bREFE{JslaC*rLYW6Y)AOf(rk`i$#h|D*T@&Tr#5}6KzXEkZ zWocavtAB_8oY`A@`rzS5kNyZ_o*W-{PM#c}K6-NctJ7x>A3lEa?ELAM=fC=uIsL+- zzTUIn|LXL!lLsGftdQBd=y{o~zuepnuj;Cp^3#o7=F{o4XDOSV8Z9(GJn7__9SoOy z0?U2;;K7H-M{2ppAAS50RE|>1{n{`0%YOF{Rsy{OCogq4=l$*rJH~ubP2K5yZY7m@ zX-VP_cM!imn~pG;%ua@l@;G72^x{Lul&Q!BFq2*5R%f{zIw!LSK5v;mmT z7q`If!RvW3t$W#1D}(m*VVv^)IO=2{S$H`|NjvBf63}h^5f>3 zzd-#jM^uaV?ARD^rhg@#Fydfk0hG+XSYWmqu_=t4a;-2hy_?#ToPQS(vs8c2wD$y*;i`UvXQcvrIH)8mgxY~`;|u*`rXBnzramMc-^}; zCnry_FweF&A3l73d<^RmM>xxdc?ldUMxeeH9o=k)z#4*=6mG3Jx2;b;w$JSqk}VgT zw#xe$)IR@aHnF@jfXG$XvglLG$7Wv^ z^TEc1-k)%8Fr=j8!9hceD09`4_6qTVvoiSnl^knXIy&OvZ`kih0eUJe*Yy|0gsg2Ls8@7N1 zYSe;~?nX%@RGN1Y(U)mN__v`IN2EW*Y;2_qNHR~EL;AsD2{h7&+9n&w+1Ny57w2^V?z z>MvFpq0#}sC|C~E$~49@`wuYTVF4Mz90Os2)W}!@GMY8XE=4nmY`RTr7Z}kFU z3ABkaTUcLV4{K@Z*MxW7v~2a^TgV**Ui=G+OtSA*G@4{ECg;p-Qkdt({I=}S4r3B3 zKMN=oVdWe9B8_sM{ZgA(2TMrSOK4hfIcV9)A^RCP2Cj9F68w-!# z65q+YnfuWKYvWfF{Q72nz2$w{4Q!)!1j&AP$DaKw+|_OSu=C>X0ExfX-ObN`yM5r@ zcQ?PzA0((_ZpmYwzPAK%yKccRcX@+rYCJ3W?&|)8tGoT${v)nzA8O!%!GUk)PS(>j zmYKShS@Tw!``^k0duslx-AX!u&%W5m1=Ez#M@AXc&Y;)og$^yp>(+m6krle?djx7H zF`HLbTqEcP|K4(r4hdJ@a#7#Z2JxkrPWjfo#Rzq~r|5(S-QI!Gaef1li5Lvj%lM`n zyy+VM+WENGq`3t)J7-8X0j}Bo=RLfS#flD%FQ38_)yQBfL9A{EdHrbDvwYcIWqp8L zKW99HIXL%M^@5=A7VrUtj@cg(9B7!);j(kK%6mo+E`khCf<=;$b@Mf5n}tMX)J$px40`Nla(o#SXROgbvv^d6x~q)sa}E6{fSK3`M83v z9Nd@%e#96E`nI;C1pv0tv&V}KG^^kI(p?b*_Z;>E-~dY}qGGH>$99Lc?EQITp`{Cj zqwB8yJl)v$G#CX}DIe1%(syWlXkV)~owE^t$SLV344GGXv?zNAl+GO{QmBP$W-Yl; zT+J1#`P;WABBzvO+hRG3xIa1GXF>!{wY>%PXhk&+xzI;;nxRuaBWHc)WR?X*>~q*L zdr2^P(e|kYm$Zf0W6e%~_4RaJejB=R?Zh}SPB;c=?}4BIjqAD{voy^i*WYabKLJLd z1ic9Ln3ENVEhS}T&cK(8aT63}DWv)ek$gaEk1b(!H@inn%uQ(@IAU)16~ibM{*NFQ z(Tuw1BP;#dsW4@>??VQm;ni$ba{z@ads$96g$t8xLM4c)mv{{MDrXu2Oa{)QWwc&) zF_k3eHDqQPAtI}18_x0052mHvsMRLsuI#cWn3CcSLj{Q7DcsY|$_n+;_dqgT$fvUt z>S@jKx#TB#a54rcCRKi&7L!%&yf5f=aNG&rKhbYfY=Pz_SNKLX#j>yvzY@HB8Un?a zci=5Pw=7H9ZD=1ky;t!5L;Zd?+-jofMbnXFFHEC$x0n5%I0CX4T*2#}XV@|wflA70 zMSKwj56{PUlKwiLaCkV=;Bd&_)NW!v?T^bJvWGceBuukc@>LrBok@ip2;V77C+e0FS~L~Y?Ynh#810+#~|a;TVcQp$X|6sWl}817C=05m(`C|F|k8k zFvDKZ+jGrh$%TOn&#WJ#5RBj9a?FlPp;(#gGU6pd*(>C8?ja)oEAcn$d-FG&-ac=p z$RyEzMU@b+kMsR{Y@@5&dTpof0GG%EIGV;Tu_-AV-CpoSyZ7UfA24*L4itruV3fB- zov<$n9*8h+B#X-&4qug}m;b>k^7|>UF}4Fpa0W6Xmbh|~4=RFif!couZ`OuE?O5uSV4p(8$9z&O$7>^l5Y>MEA$pxMGSW_{Q zOJStFCgYh0_19_!8CwjP9n0FX1X) zzFFfto5MY!p>t-D=(4}IFZ#)D=q$THIr|h{^rRtz~Y3l%~PihE@b+?uwa(|F{vT8&^9h^ZVIg? z{-Xu03(=F=9@c-{=${#curnUfxT^p)ek?_1#S|X;;lgglZH2@K7`aUKYk$kTa`@Z` zySA6%uVOHqE@pITD!^D*7_)DY2VVKTZ%a4B!gj?%ge#n)$-~K<5s=$YUFwGb-G1*7 z#m3bQf$1oSDgX%tU*}-Ey6*Fz5?f~|paYg+aiisUHw~&rJ8SYQ_4YR21WUW+S9YF< zvgeF9VTS|yN2qJjMhCab{hBBmKYt<@KV>Hpiw0sC*ev|z1t-vQzF0ApAK!1ett2zB zs9cs#WDsO6^dc~{1Trzs8_JM|EMGo)vtDeNvcaTsaR0@n$&uL>N0wi!E~5!rP(vmt zvap7E0 z^zUUx85H;};2bUQQ7Fn{KieNYYxTgHpCU|18LjT>{yOn`KhhwXrn&6PH|r(9X?B4h z2>z6sK3IcsO8dIozL(hL;i`k2iRGE|#LRN9Q3G8wjh?$M4yBI{SrfOnrpC4*K-DHz zH1vN&da!=*A>@`}%u788wbl4llhob2%L)eMzm|D#^HX3%amBk~z@Y{YSb`+bwn!Xj zp_Y_iue9Y z&;{udnIum61}vy;HYgM4p9PjU&}sjj&_274Fq&h<#!p9ck%Amh9Z%omW7W&D&(og3 zB$jycs7(S}%wNeS>ODDlk_g(+*s=#pSv5=ty-Ac8hhxyQm_v8#)Le?hTMSlNgZE7= zw~I6AsQHHsUE84TWBqCCSPL|Cp-G{xK+K~3@?=hFWq`%Udw~T5umTJ0X&YbK%bwYt zu>6l#AS8HC#j(*b6ON@j$d?kWi%Ll_R2Flh*>8-xZA%RpKo zl%UlPB86W>z>EUP0*;}Dw7^exs)r=Qz)s}j#E!dZ^Yvm4Q{=Ycon9XHf(6Q3<*)!7 zXnJ=-6AOu+fz98zvl*0&x1~iX!RmNV58bnls94aIlF>Uk;zdC61eMgRDLBJKkNvec zCeP=^YO|h9HW}z`7MtwXnTUNKoP6K!8yWEMWbxp*!)O6_QJUI@5OhZFnb;o{BIv=m zH1x?&8u~CAo0i-<1Jj&A2ID_Pq9?q7+a)rU$)=+vu$Y3g!~o?^$ZbDkw-V5sfOZr@ zvkAw5(?rPzz4%UooAhltRmz9PC2KlIbffzg_Gw!0YsQWhL~i-bCG0rKk5iM=gX7KS z%>R@iFMvhCWojJ^{UjW(PxtW#5$Sy4suYBnCft#;3rz!CVx;W-UWaS`#TC7M5M~p?h7{i_|681-Iftz|SdsU^kk~D%AOM?CNU!9!2LFIOnq%~N9 zB!2@@>g&n!*=F@IAxLbHZ3u?7V%{Pa=k#ab$PmiHyj<*;Kae_(K zf*svI zG~K)Lnd;@<<7C}y(fPtIq&rXVWfBtJ7O_`XjvWj5SZU5u;*wL~POThvSq=SJQez_j zZ=9{kv*h@zr4r2pz->1o!Pj2hseR(#tn0*@xn{@QP=e{aU*6W$WH$ZeC`Xq%)`v2a zQok?1>#iu#LKBpD{^;EalznJ7YFaFmtfF^Xq_1!oq>=_F@2RFbMdp*j$>+`;R8X-g z_~dBZNF`Kb`UR&ALhKjJ%-%+Dm?%7*g}RYwP#sj`v88=bI52gcx)gZkkGp27fbJrk zvL;t&f&L$0{L(-n5QH>FoYY!&5#5RrQx{}w3h*_`+8{v^6rm;Y`-Y;dYxY!#L3c_i z76@$qB5qfPaUVZTA@-N(Ca0BR#sDu|Tzj6iNow$#U@izvQo-SjHXm*E`HFz9fT98# zq|g~7vV{K5qJe_0yxb%a&eKP8?T+RekLLR49nJMGa5UFH=V-3?I+_Ti@iICmS*++s zE>CzVnx~I$Gpz)etgWC18O@99^_LrD9j`Zjr{5Eier zZ^A#c5hTSOwhYjbrXbb5s%|PvjnTh;*VC_;D!V}2%;z-@VKf@429duw?yU-~#g=Q_tGy8frFTwM@8@R8RKY;T( z?^VnZ*!A*WgSUp;^F)LQ7MzBA4^^uu|1JoX`|Q%;vb*;dQLIn6N%(%l^4e?(4cY3d znz2ZpAT~gg0Yoihbs(x&F*Jr0+}RTB4h_MpsKqY9I(m~|93-tA=I2>C4)UJSV!&Qz zy{4a|aVHGr#P1dnh8H)Te0+G(IkPKaZF*_+?L6?;lQ1n`!LdVFW7g&!bT)_l*&buY zx-PncIjMmd+7Cob_!ql{2luv#Ui9}d(!1}RgT^DqHMCvamf+RU${0Sh3&+#3Rr z{Lh>h4-3tMgs(2Bzzw1BBz=F4h|W@6VcP+fd?qF*@vUQ8qHer74Na`O0^W+|}=9i)tu4tTyIAhvssJY~(_f zA6vTG_9X~~Eb4n9eH;Yuy*IBO1dEOwz7MuJ^ySBvasDBz@V_vk#0ocj4i7Xi9He(S z*Z{SBr|1l<<~2C^>JiWkmg+c9G(@jYV?*;w;N&46E z*2E4+GO6s`80_i?;AFhoG>(UuW@^GmbJRgCG#`za?d-dUEBE`ZqYL3`u!6W5!q8BO zk4HV^VUL(&7K0s+<{ww1Y+4S>x-RBkvS4+GGtpB|XYOTox@?+Clqy`{NLLumgeFg*mqSFl>ZqjylR0{{cP-eeeufS^yk+*=H+deRX)Y=??xSN|X3j zi|g8U$|LV;`qMh!T~tVQ*5dk@rw3oQ@<&3tlc!6#SI+Z{p=RL)IX}Xo`%%os;wa7! z5Gxb)-U1-03qm$7fZ=(0TS^Gx?R*9x?r-F3Lt;Y`a#xMDX-_k302A8p#tN!MiEWJd z#b@miJz`nREps#{%t{#zxN}Zy;J&q>1TR0*7>Bai9Y)u-dkGC?HTh1CZWTbDBbszm zfSj%T4Y4TFUklMeL^DCfhntinZ>SS9<|R|-WZH1bEV5Di9qDD?+%2%S20%8taA1t< zxWtADofJ9Ar(l=i7LV7no~^53?h}3|AQxB-fLJ16b-JMl6FN7jef{y-8q3&PkRe|0 zDJ5VExDp`3`#2y5{3h^_(^e*ua09zRDzC^}(eS`$$_#WN8MZ9o?r>Xe^8qz!AJF(1 zX?HnkLsGyxjuK_aDsCIDonNuP;ZdANVNgc)IB|+WXFsv~9c@ysKV;7nhsR}<8OtRn z+Zo)5IPj?HH<6E(Ps+&q&5fJ!OWQnb2|O=nWj#Guz}-WSmB2}sA&}ez0xv4!k3whP zXpx+wMk2aFJ;ia24nMsuR)}Llmpm|rV%WFu1R}E~&4UDTM9}#qya9X)5pQB&HHUMa z>x>OsH_6B*byW(I?Rj!k4E1aF4$NP0*g+UfgU*KWe!cb56}{EwX=w5|*N#Rrt2pO{ z*h$6Nd$$|5NnOFFyQjcP>?CL68?&O@vf6S}g1DZGMN1L@#C|^hTa5N((9USr!pI*3 zdkOjFHfN(lY|zQ$q${wlgu}E0aqmM$UPeSXGtV$n1MD#k3ypAS)O1 zQTqOmauubyCyJ#bg=^!EwJUB0?DwK(GIB~8oRRyQH`7Gr?nh8)GEDo+MEmDY#VNaF zOZ)6m>Y9){BQXXRD?zpts51{`+4#CbD5uyW)1=D*#WLkG_mg2jmPY23ku&Z8U4X!B zAvj9$=-!O@ftVCeON)31v-#C*W-r0F6#KEt(+;nHqe=WtUH!ko9(jacKi_hOm~e{+y240vPOJpH6UW8x2?4KtN);4JZ*B@l zX~ZD@ytu0`XY|ZeXtwX;}w)yKgwy7yKSk|2LaoV$s2YrJUKI znnM0FsOd}zb3s@DX@86Ivp|7jz*uj9--fw6IsSc^M=un?CKt%ms7Y?^zQ`lMD9#40 z5jg4iQ^p2npiTp7joQu=OOm%2E=}^ z@_$|xcN_BsZr5~1Df3$PUU6Bn$n-V^sdiY)L^mv8_TbL1ifrp4q)W{ zFrnj7UePurtf!19b*f8`CV?_Ao7E)uujoxswcuVcJ(KX5cwcevXrA{HS3L8Hd=7tI zF?SH({ImWV=ha9D(T-F-YawP<`!Gl`2N$6U7u(=GysN>avZok7$x+5nU4(IQ3-ao` z0uM|TTN47q#fV}8^rBzE!B*p8acjrG0XBRzlH{-%HNPd!UG*CRnh@n`aV!BwEVtu2(XuK%Dxa=Q}t_Py*O&=-SBwP9Vhml#vnVl@HBEr_b7D(l-TWNG>V4=%~1X z!rvHJb8`zFLHt1^zA{9V>C3+=x{pKmB$U+WNzafo9H6Vt+Mo{j3pzXhF6j zb;ZH29@D+fW6b7E-ZtlS>d`a*)s$FX)<_jTZY7r#qM3R_$Kg)V%`JD)ew3ee zK`4nkK}ls&-Bb(mfT6RX2Z0e$Hb=&(gKX{;Km8(@dEr?N6xGt0&;?Yx?2RhuFh8_x zT}RAx#Nw&d@}97Y8h{DRe1lEsgTX(c5x?*jy#YNLI7OLVf49h@{nVy?v4+{cTnp4j z^MeEX&Hn2~+ngbQpSi|jCa;~JDcTBfG8BSdNQ!uwGv%S;>AL)DYJxfviGo@5PUM&r zWxou5vR(Fnld`B6D(PwMcmZvx;M3`{!otCE&*yj18tM?P1*(Y*PT5%uIcd7YoRT7; zo1ET*u|th7I`_s*k0|H(2ERfk51Rv{=MOkCrn3ObYIxY)OS?~L@i$7sU(ZO>o)mzc zJ6&V3UQ5oYSc!yaxQ9%Lq@2LwQ#XZ3p8T%c)!6-@Exx- zs}GMiXRn*{hbYuX5Po`w)1!U;`pufU-|z;T+&@CgIDX9F&RZFuBS$R# zg`J>6l2^c!x^sK_b+GRW7?c^n z5zg0mIhsQq@>rd}Ofx;+VWX*O&dUBewN3rp@Bs|xh(G;sW?xhh@$IWMZnf|z5XsfH z0)u!npd1*80L8J&Cq?;f;MpJ2P z`hyQrh%`4$u6jwF*?(*2V24|uZ+?3o=o`?09{4Rp3FaUTAG&|QoW?WUzQ7k!^_~0V zMzE(y&U!|bN6sGhj)R(8M5&1s_%azT;XhqFvj_Ps(uKB~Rm!wm2gWjn(>KRqe!+!H zzv1!)zy`g&noY`GBbl`Ak?uF7mLz|lfVO&rI2s1;Y8X`Io&;>p7DyyS$Vdue!GJs$ zwi^AW2wDC*MbnLPo#5&5^?r@%GM8Ql;)U>-^=bFS4>y)0+_@%6(I_F+bSDY8#07d^ z@hrimgPd8%&W$dhZse|nt|C!_h~W68k7EjF1_@cb_$oG|yNh8F3^28`yD@rUBgQcK zvh~ot!FfxLKX)Qo0F~Z@o1DN77Z1K$d@r6dcDjFA+wY*&`C&Rc{I`xuUu&N`R%Sde zfrc#{NKC+c3xjh3SOmPu5doQ_voGH)VK;i<%qwURo=rPvqra?UWb5kY{Mm)$PKxTr zz2}TV$pmTWyx3k@>PzRc7M#1Dz`{)cpc&ue*BC0euh%N|#8Qw}eK+Sj)@3AA%b?73 zp9Qc&U0sYkkL9*YfG5Bh4sxLFf_>`k{1GG?OzuH@|Cjaqm`#KClBzE#cNtd7`nGb>p^PB8Q4`^u zR3nvo#LY@NzMEE1VhVFfaFHN2o;?eMi>}T!MI8;wMd5ESOd-Q-%*>m@T_I-07tb6Q zCYy$r41cqGiXboh$}S0qYx{tr{xfl^Q*Yoa%aE;e+pN59qe@l&g1es15G)We3b@Km z`LXO7b(NN^{F3}}$k!faeY^RK?6=3D>X7V7EqA$x@@902^xYxNF_9Q)NGpv3I^d_9 z&KaR9bjOAlQQc`jXZg(;s9wVAii{O4%-wXSxX}Tx1`7g>tV-u;;@!9mlUl3F6Q4;2BhnB>hAyg_|CcR^6_K-%JbMG-he(6AtH#Rqyyy1*h4Z$$iA_wEcDfbE!uM;5`hi@{LM*#KJ46+3CIY&6{gi z@g=Rg?uHA0b0nsdyPBQnV9n?Tr)NYxi&jsiXL5+-4Y^BigHL1uRc^y#!TvmFHMz+j zbW%11qAjAn=caJXFdYEx!3@gtTzYlZ+u7aZGMptGvAbteT~D~JYpj>SU3*uj!V>$G zML@lb_5)doy}t9NN0b}1>$ys|(fB&BnBkKL--X|W`Lv)E|3V(xd=@);0d@^o{JH<`Rr}!WZwqdz{)&~<>@hfMoQmwF`G#a z*jI1XqtK^;ZAc&^U1lnm+K9w7S>%!}IV0_gU+h7h!cdL&M1@y|s<_BbLCdTze z?&A$pksEOr4%ZjmL0p3wZEiY)pw}t7z@rz;9)J`yorPOc;f=*PMasn4)Em&zBuD%A zB9Gv1Z!l5C@qBtaR&^=_NUEkqfXyNZQ{_rRlM-5xBeW*{4ax|;QM>43A&pTgYPe5U zkUR4E5EokFbV8L#hDsF3$C>fuA#0izj&hzJ2!zeORHQ}rInY|bPzps z&N!pk7Fig9?b|n_ObqD}+{iU?IgmuYtD`F6RQdZ`1n0O!$CRwL-WGcSq8hznKWgK_ zf8s72UUdEw#Af1X~+8r3`MW+L6PqKZWuJAhT>*t7jLF9XX9Z#5D5-# zQ;@_MLkJaDTuFFyW9d=4qE?+qs`744p*^0DB8ZNI)P9q@OCe7G*_>wE>bp`JYC?5h z#26ARiFWA4n+3|cR-(QD=I=`uM*pqBNbsK97q*OxIFP|Q+QFs)LxB-bVTGj$0g7r- zet8_wP}j*qOglZMSG&QzO~I&*P&#Gl=cEKaDp6Y14Et%S2&fWtvjqGvtKh zectt~nRgD&GVN9uGq2OxC z$4vl;`dp;;dyTu*0fTK@Y?7Kyx!Sw-M;6BzZ5L!aZOWffoB(XA+j3sbEZ9cQuZ5n1 z-EU_CZ(j}|V0LeCTlbTko5<5oIXFeG?Tgkj+U)qb#gF+#o69>FAF(jU01O4lQ|7{x zAly?r4x%dampd+tPTS_4R$NFDdX0yBs7#kZHK|_KHwof zOhyf}Bk1((gq*2~3#}G+g{1wso_eI{RSR2S0WFJ#9i_Nj>9===S%iMWY$%VfH$=a< zTPrS)q04~8NS^t4RLQNc12b?K7kh1Yen8;Bj7e)Sx6kJFQuNhQPBL^6aA|w<%aw-GUmw+I{`8gwhCB(4NeO*KaE<;0m&NBy~x4K<~GiisS7D zZUBuq9C79)VKD9F>TVuTez|;5z*a}w4qrED9W8eX)YEUNnzmJ_ff~&pHw&YSDesBW_%Vr zJ;Y8pam>p@-DPKcQ(AN9Og{Cz_ko8*6y$*w-4BzxEPiWUT1~T z3mxBp;bXZb(k_DGmg65*l=Iw`7eVC5@czmN-saqNHzKgVtU@oNMh1$P98BvNXkE$5 zApu`4=3bZpx9mT-#;i4v4(eJ>^ zqOrL?LPVWu#104t;HsD*jSj=|h`MTixBt9JW*z3XqRkfE{Q-?&TTI6RhG94%uP?~f zLDbXshQ~@pvXRT24=OrPOU6KtI(O4{80X&ismt_8@VhsnzAbHY0lCn^z|7$fclKtX zVTy`7Hx{eytT_1CGedllop}zCwla^Pj{rA@Z9U&!e7GCdc~dVRjxqYfkBjH6*)nzJ z)lD%jrHH%ivFa?$iId>_bjRc^pk+blSE`5K&IQvpZNZ^BIxUrE$6-8#C=Q)~&o6xN zgo}<0oB7h`K_NCA7WDcS-`t?cJZK9^tJCF+8$2=E)&0m#v=Pt0B_rJt2A(>v;=Q+7 z&={jms6;726Sv-#H5RcX8_fkM$A*P2DJFZNN2x&a60XHFv6V~f6lHYUHYc=2T`2^RxK?E?-anl9NBb;F?>iWtc}O9X+SgA!;?{ z@+YMkGOvF|wO2}g-Br0yI9nQL15D>hi|X@*aVnwRypdJKRq!KJW{7}Js=u*SNmBMj z@w+OYiBkcUMwJnt7L(Z+7EQ5ubviE*99D-;Ys8^>lBp;@N9|Z@BymwHGAW9hf}Ce9 zfDxu$p-xbcwHT@yBis7P%1&^#%0^!cyZ8j2co;(d}*dIh6HcGR)T?1c#;H4|Nki-Xd-Mk)Q>3bqNU5wPg{o|TlTq828JReDGQFfRk=(+?N%ZOvS)l^bn^z$y-uGdRpSVvrMiwM+y6ljp(}Rp{h?ZFPPMoQfc?P zfrE@LE5hBGsKgy_?e-%>&8YFQpWUYYZFe8Wafm)Xsab9|fw7Ev9ccA*64|~UwJ&BR z2J0n1G|61DblbU9z1<|f=?;wQmSPQgEb)|6N$jJB)jME#3n=RkPPo&>4=4Bmn|7q_ z*dY?}89as6oE)$My4Ms6n*-u4eoL569GK>jV zUF2~P?zTk63E*~t#01^EF|ZTKK-<(r2c^nb%RZ90tBB8Uf~zV@(&FyJT7!ES3n#gYpmgTw$SI5Iyiu;`eZoq^xyaoN#;!bXS1dIJmg9B1M-K;BZfM~t zrorxFYPsDm8o0)h(dY^~Jsaytv9U^FO#$@ygxw3o%iYaeL~iJc9}&lu`;N?5buFba zhBWaQ;sUolrfDVxPi&oyvO%Fw>zK#Dg9M_Nf^M`rc*u#Ah*vZSONIAHp_N*f!J9-W z`>QlY%&(isCBJ600;#Wz*m0ntvR?4DqZk>7+a0Nf`oYdAMpD9G$M{@lD(Y@2mUYq= z{oaZjO^h3bXtZb&Io}KD=58^BUWM*Mx@6SnA8L(Rr$u=fm`@FGK4`?ka zmkZt$gH}W_!jiY0n-}80)`^*79&<@2;bx`gX7bZK&S>7kku4VOgfEv&j25r`J06lp@1#7$QP zNV30j;E)UDJN*Qu;yHTunQB30ODv0d86Fj2aoD`Cs_PPM;3H*_@cTyR#Uw~V()H#Y zh71IbD0p<9(-F>e0l<$VixaE6be>|H2F2HZtZZnjCVBS+u_PCwl$jESZ3nFQI| zi*b%*N~nfy@nkqlr}Y+B#GHK?!g$?b>BG-G`f$N17WN$paH?D21E)^xZV%lw*ihm$ zy#rIy#WYNI(e7#bdV66P<^Oc;!XP(p9%tkJ6*?@ed#A&jjjL)GJZ*m+A@|Hz$_xWG zw5L*Hy58~!d^OanZkX%$UItmafBsT%65&c(!j{^KTOCQc({=+AH3_N#ZqH5s8gqll z-E6?Bl`SE+>2xulL9`VL4d><6oGgQv+1H?0bvdWL-UtC-IZyv#SwbYDy_mNXD3Oy5 zOHjs5vN>t;to&n{KcE17J1}9!isIW8%yJqHaMc==q4V_33=;2HHe!x792qIqR|VE4 zySClT34I{eurF1MUYq0gxEO4V_c}@4U7b22%BDq`UG9-h%lia;^XjYCB$TgJQb{}z zj-Y_UCWlRO3BoiF-im?})9brQp=%cejS07g{$Yb+zP=Dms$y^=A2f`}m}R)2 z?fz#$^sY9puy3yRkRIpCbduD!wK__>!lJ^RX2jVxvI~q4WW!{opRJg^Aqw0aTbhdU z2loGV)$nCuH0BBw+dVoQ2mYrGXTd!5N24;kxnzyUyFtEo!NiZ9i4z??+}w@%gjO(SNTIz0lOOhxvlUmjfM}RFw#b#@=F8 z4U@u|6r9rpiw++Z%Y#;wlajPD$@F1do2|NvhxsR zz)EQMGtn5nj$_Qkv=W5F#MY=p2Jb*fh_imu4DXrulhG9?`vxbg)!$0xIOqpZ$D^)T zG=3-FqT`yEmmAaGp;y#~yw0{$XVM`z&IK*8x8;7f@4x2dQ|WAiw#2w6(@;eylH0iC z@&GsUaA=;b;;52GZ&T@_!%iH zAygHCT?O4;H$bXR`_e_hiIQ|Rof{%XSoCf#rx)W}GrOCsG>Uax2!iv|!FExtMV*w) zI5=m|TZyHCdw`^%{IQrYhk;H+jrt_pkO1dI8IyC>AgDiLFRpUg+lp=_8IU_?-HCW_ z;2`3r@61X;sgT-f55m?I&tzI^oOn%MvbYi*BY>Qt9+^>}-bV;Q(z`Blb~q8B>TA2(y%-`Y~8eRRWxC7x8tfY$^8h_ ztgX95GNxmNh0y_F!KogD*IQqW3!H5OJ)y9Mq|<@KqFerp+<{u0Vw?5kwvLkCfR4&x z6C9%7gx5`HMw2=;-=j;ipmrucG_*Vls1eaGf2=SuoP@w7a_|Minyp~i6r+S_ABYvj zG+oFd`hL)HFhbajT+V0Y@NN7q?c@1l@ibcr$An_Mdml=8TPWYfNXfi~cp~p0LdvEY z1fPH>h~JS7ZQcTD01NGRJ`SI^xjf~?4drPNXjC|7jQ7eqP> znYw+B2?*H+wWpEY4|9}wE8v!?hqs4l)iH`hb@rePDQ3`}OxFxK`V9!`k$P&>apu?iF zpraINk#A8$LDR6D8{S$fL`us1Alglxkkgac5&2#9MjUb)6usDgXB$dm;L*PB|Bu!Vt)>L@)~CLy8IXO_5Yq^8l~~R$np_H4V)4bTm4YCLNiM1O#X+ zD*E1mbrz2d(S(NDX08_{6_Z1`WfDCf$);tQT$7v40B(Hwr?4=Mij8HJu;nthIP>mt z8{yG;>fXs+!D0 zMFJ%Z9wdL_RPTN{h_u*`7GR3+Wjf)L&$^_Gl{sf1;tyEdu@YFBQphp41R-qgpf3)} zW32^ni*A_q&>hjP1uvCW=$ZQ&NSJ|SECBc3=qnmPL8CP3n4EUw=^A0&{*IGWXQ33Z zix6Wo&H)b2JJOJAlJ=gFgd$GCZ?O=2m)>DK8%7Ol??Iy?!hLd>EqYF{60mCZtg&4? zT=K@duU*$We(OEo#}#hNo||ybv%U75^hCoF>9PR zV)g2K!yZNLVbWp?1rUy75r^^Fq&OxAs$;A0oE69U1nz;cS=J!!xrscf?d9vP z#4?;O`?q$dvK7*MOhk$KnQ#vvk~y5A+g3PfFxCe;`&G_S1QNW0kyjxRk?5;xB+yg* z3@R;lHqrvt#Y#e9m?l9`+|`<>Cdft`X7agaH!dA54QR-q)>0M5`};sRE{i_mp+z@5htdu;%J!I@tQZmG?H*Q-vNZ9 zqTb!x0r0`t5AEx++1-H<3ZH&g`7U%rRwmQDj0L=iv)v)pF|)|=ZXN;z00MT=AGyS- zxxD|9=>@E+qi}b3yI^bQxEUt7}m5)Bd>+*oB2d)S(SG=nU-&9LP zFi0-~sp^4_>8;+6!A<;GNPlc^w;^S;Oq6fCD+rIwS(@FlFutfJmnG{!C92qUb>M6{ zkiZrK!F9}*3uv|vZ?x~fT(j|kUuo95OtWj5nVuzcJ&vm}Z0~I6IlS5^ad+vk2v1sXSB6!I7eHB-+(Cng+rSB!ihvz8ZZi^yp4T+CH|;n$}zJDo;xJ{WUb zu4a$}0{D7_iUwOxu3*B}V**sp8JGwuJ>3}!nLP}{H2)wLWFIC&G121Ro_2c&q7tNM z*-T6q;@^;6EEBeZT67U8Dc7S^MT)mcs?@_}=E03nr64qOE zPgkp3(LJ@r4s2tPzHC^yNILC?W>GAO9_gUHbSiqdduU_a#&32Awq_5(_Ny(rrPIf*+Sc!m%L=;p2?G*2?TuLHy~T0EKf^c-5_HjAf;oL{}*^u|D@qYyKaU1`63Jh66? zLGQ7!wy69pg&|!|vFI0^LbK!}pYcl0c!kcYpRH0DFGzxiXeKNKCG&S~s=O9E7R@NR zvodi%w;&2H(V+=$Pj)`J^O^M)vvG+qh*GLYVRFJ+i>0`__Vf#C`%SQYZkCN48!e>9 zM5JbGI;z`ZUToMu>HH_rEyO9K-Tsi78w78U%5bsTis%95kix0On-TC9j^6}5IT<1D z&M_4|=Rh8z-~>r@M7K*SU;^jWb-C?#%u$v9YeRy5l2=7o#y3DA*v?<)cF#-};8!G5 zE?L0-%9oCl0QoT86EXAZb&Cy!XLNX7?OWZNd*L*Zd%=WumM`C|-8jyfD|6r$I`o@_ zUaaZQA9QK4<)QnArOJc!>)A!#@}7UGk;5wG(*KoHICRIDm=As5!c}ksJ)f1r4EUa7 zQS&tQ!BFHYIQ=tHZ%B=4HFXUi+7sM8caGsF$lA8w!`Bd|TZkryc^F+B&CkK?nnQf` z#@=M-BcfelHPe!gw{hI4q=1ofm83hoFIR%yb$X9&{vMz*K%W;C`0dO1Zd`w7S9=G2 ztJe?R`?N7*Sak;zlo`!_vG3wJN8Q~ zes%CO0^SdHM7tlvJg!84Hn-Ru1}az=`ZX_aCk2PK6U=g*kAsd5^`M6<`>@jz@#FJ8 zZIb}II%=KtU(O$*Cf02>9Nx_v#J(ZL#zHKr2DZ}ft&G4JGg^2pSP@s_NN0(2M!T<2Ba^AWxuTLP7tCw1>V%iSsFjjfM#U-Je$&nw-t3vJCnIqPzU0n4_z+4`*vp zLQM8%bIwI1-5RjbHQU()=T4rKT208CkK#y$?ryeWc@Jhm8w*&*y&qQ)cwZPrikFHj zIBx{_-IE_!2K>NsZ+>9u`GMuV_yNg>(qd0ZMfxNueN5$-0fprnP$^2EBJbx7!oExp z_L!)*57{8>?}++4qW=F9QEw3Rdl2)VOmSgBm>O3a zjp7M{d{^T5zBQ*L0ow02d21S+>2OiE=S7^hTEZUYI!mJ2D-&|6P$q_CN|$sMw$^^w zC*|;d30iuRj3v$p{oq6;V>t<|6WyexVSHFnJ{J7PU9uCnWE6LvRb+k$2=N{|*~HXP z7yfKZhLxI&rr{)a0?9F#dgVw172Er-Q3hIuFU8*2}(FLN%ufIb0vxE%Ir% znR=zE4Gi3gVtWxpN2(Yx$RXAUuSGI= zC9vg%Yj3PHjOGdgj_mp{r8M{vbopPu%GkPFiQ|S&Y(PrtIyUO^tO#*p@~g8#{1u=r z(rH+sG zl9GJ>XUU3Zg5sfZiGcCJnGeP-w0~OeV5)1HPUbLh!f7VdZ6c0CnWQDG&4cBuSr)F7 z6G^l>=Hy#al`DWUQLvNCJmkF$(uw8F&Vmu*YVzvt<_0QJ*=fC`(rm5e=q+Ll>W&n; z1Y%OvqiQ1Bt)S$tkX$j!$1>u=YSsnamPHj39&_Hl5q}741IDXL z)qxCRh_UY17v0d%7=M* zSYPD4qPIgQ5P{L*QJ_KzyMh+gJ<4xUA&OW_8&V2;B{tcjoJ_FlZtHD7j`Gw?^+8J$ zeSMoKPe`Nc>>!HgN8a+@wN*q&IYCIPzDCp+n%cc4~i1@u7 z)lT#C1D_AhY^~YhOuTzmHtouGn(!>4NI)X*f2i!l{kvfYXBlg|KD^sJKCh0KG+6iG z#LZ9U#GOLd>`>^^q@ss@KEOnq6cbH_yS94=QX(D9hqvR@5>^F$XikHXI<#0V;3jXj z)+q8i6D~yJia{$nVTzwaBup!j$Wlat6NDCX$pw@$^~8+8WEL?3TyWt3Z+;-Aca+P- zr|P9zGEK^t?vd09*k`O(Y3+4iN*DyY{$gxgH^kY%E@Htmdny*KEsU!+cnr@GA5{;NyauwepJi{m0hKh`*l*8l;%by1>^BToXS3PDVeoxkm^OMN&voW zB00Dg0Be96=mV!QJnl~{cwOUvr#@mCt(Ydvgw?KzjuG{{@(q)`+N8I?(f~-yT5mT|ERzJ3u`nL;{dn;0AXiY At^fc4 literal 0 HcmV?d00001 diff --git a/submit/routes/__init__.py b/submit/routes/__init__.py new file mode 100644 index 0000000..ef9b01b --- /dev/null +++ b/submit/routes/__init__.py @@ -0,0 +1,3 @@ +"""arxiv ui-app routes.""" + +from .ui import UI \ No newline at end of file diff --git a/src/arxiv/submission/tests/classic/__init__.py b/submit/routes/api/__init__.py similarity index 100% rename from src/arxiv/submission/tests/classic/__init__.py rename to submit/routes/api/__init__.py diff --git a/submit/routes/auth.py b/submit/routes/auth.py new file mode 100644 index 0000000..3d79ceb --- /dev/null +++ b/submit/routes/auth.py @@ -0,0 +1,24 @@ +"""Authorization helpers for :mod:`submit` application.""" + +from arxiv_auth.domain import Session + +from flask import request +from werkzeug.exceptions import NotFound + +from arxiv.base import logging + +logger = logging.getLogger(__name__) +logger.propagate = False + + +# TODO: when we get to the point where we need to support delegations, this +# will need to be updated. +def is_owner(session: Session, submission_id: str, **kw) -> bool: + """Check whether the user has privileges to edit a ui-app.""" + if not request.submission: + logger.debug('No ui-app on request') + raise NotFound('No such ui-app') + logger.debug('Submission owned by %s; request is from %s', + str(request.submission.owner.native_id), + str(session.user.user_id)) + return str(request.submission.owner.native_id) == str(session.user.user_id) diff --git a/submit/routes/ui/__init__.py b/submit/routes/ui/__init__.py new file mode 100644 index 0000000..0273bb6 --- /dev/null +++ b/submit/routes/ui/__init__.py @@ -0,0 +1 @@ +from .ui import UI \ No newline at end of file diff --git a/submit/routes/ui/flow_control.py b/submit/routes/ui/flow_control.py new file mode 100644 index 0000000..6ca0dee --- /dev/null +++ b/submit/routes/ui/flow_control.py @@ -0,0 +1,290 @@ +"""Handles which UI route to proceeded in ui-app workflows.""" + +from http import HTTPStatus as status +from functools import wraps +from typing import Optional, Callable, Union, Dict, Tuple +from typing_extensions import Literal + +from flask import request, redirect, url_for, session, make_response +from flask import Response as FResponse +from werkzeug import Response as WResponse +from werkzeug.exceptions import InternalServerError, BadRequest + +from arxiv.base import alerts, logging +from arxiv.submission.domain import Submission + +from submit.workflow import SubmissionWorkflow, ReplacementWorkflow +from submit.workflow.stages import Stage +from submit.workflow.processor import WorkflowProcessor + +from submit.controllers.ui.util import Response as CResponse +from submit.util import load_submission + + +logger = logging.getLogger(__name__) + +EXIT = 'ui.create_submission' + +PREVIOUS = 'previous' +NEXT = 'next' +SAVE_EXIT = 'save_exit' + + +FlowDecision = Literal['SHOW_CONTROLLER_RESULT', 'REDIRECT_EXIT', + 'REDIRECT_PREVIOUS', 'REDIRECT_NEXT', + 'REDIRECT_PARENT_STAGE', 'REDIRECT_CONFIRMATION'] + + +Response = Union[FResponse, WResponse] + +# might need RESHOW_FORM +FlowAction = Literal['prevous','next','save_exit'] +FlowResponse = Tuple[FlowAction, Response] + +ControllerDesires = Literal['stage_success', 'stage_reshow', 'stage_current', 'stage_parent'] +STAGE_SUCCESS: ControllerDesires = 'stage_success' +STAGE_RESHOW: ControllerDesires = 'stage_reshow' +STAGE_CURRENT: ControllerDesires = 'stage_current' +STAGE_PARENT: ControllerDesires = 'stage_parent' + +def ready_for_next(response: CResponse) -> CResponse: + """Mark the result from a controller being ready to move to the + next stage""" + response[0].update({'flow_control_from_controller': STAGE_SUCCESS}) + return response + + +def stay_on_this_stage(response: CResponse) -> CResponse: + """Mark the result from a controller as should return to the same stage.""" + response[0].update({'flow_control_from_controller': STAGE_RESHOW}) + return response + + +def advance_to_current(response: CResponse) -> CResponse: + """Mark the result from a controller as should return to the same stage.""" + response[0].update({'flow_control_from_controller': STAGE_CURRENT}) + return response + + +def return_to_parent_stage(response: CResponse) -> CResponse: + """Mark the result from a controller as should return to the parent stage. + Such as delete_file to the FileUpload stage.""" + response[0].update({'flow_control_from_controller': STAGE_PARENT}) + return response + + +def get_controllers_desire(data: Dict) -> Optional[ControllerDesires]: + return data.get('flow_control_from_controller', None) + + +def endpoint_name() -> Optional[str]: + """Get workflow compatable endpoint name from request""" + if request.url_rule is None: + return None + endpoint = request.url_rule.endpoint + if '.' in endpoint: + _, endpoint = endpoint.split('.', 1) + return str(endpoint) + + +def get_seen() -> Dict[str, bool]: + """Get seen steps from user session.""" + # TODO Fix seen to handle mutlipe submissions at the same time + return session.get('steps_seen', {}) # type: ignore We know this is a dict. + + +def put_seen(seen: Dict[str, bool]) -> None: + """Put the seen steps into the users session.""" + # TODO Fix seen to handle mutlipe submissions at the same time + session['steps_seen'] = seen + + +def get_workflow(submission: Optional[Submission]) -> WorkflowProcessor: + """Guesses the workflow based on the ui-app and its version.""" + if submission is not None and submission.version > 1: + return WorkflowProcessor(ReplacementWorkflow, submission, get_seen()) + return WorkflowProcessor(SubmissionWorkflow, submission, get_seen()) + + +def to_stage(stage: Optional[Stage], ident: str) -> Response: + """Return a flask redirect to Stage.""" + if stage is None: + return redirect(url_for('ui.create_submission'), code=status.SEE_OTHER) + loc = url_for(f'ui.{stage.endpoint}', submission_id=ident) + return redirect(loc, code=status.SEE_OTHER) + + +def to_previous(wfs: WorkflowProcessor, stage: Stage, ident: str) -> Response: + """Return a flask redirect to the previous stage.""" + return to_stage(wfs.workflow.previous_stage(stage), ident) + + +def to_next(wfs: WorkflowProcessor, stage: Stage, ident: str) -> Response: + """Return a flask redirect to the next stage.""" + if stage is None: + return to_current(wfs, ident) + else: + return to_stage(wfs.next_stage(stage), ident) + + +def to_current(wfs: WorkflowProcessor, ident: str, flash: bool = True)\ + -> Response: + """Return a flask redirect to the stage required by the workflow.""" + next_stage = wfs.current_stage() + if flash and next_stage is not None: + alerts.flash_warning(f'Please {next_stage.label} before proceeding.') + return to_stage(next_stage, ident) + + +# TODO QUESTION: Can we just wrap the controller and not +# do the decorate flask route to wrap the controller? +# Answer: Not sure, @wraps saves the function name which might +# be done for debugging. + + +def flow_control(blueprint_this_stage: Optional[Stage] = None, + exit: str = EXIT) -> Callable: + """Get a blueprint route decorator that wraps a controller to + handle redirection to next/previous steps. + + + Parameters + ---------- + + blueprint_this_stage : + The mapping of the Stage to blueprint is in the Stage. So, + usually, this will be None and the stage will be determined from + the context by checking the ui-app and route. If passed, this + will be used as the stage for controlling the flow of the wrapped + controller. This will allow a auxilrary route to be used with a + stage, ex delete_all_files route for the stage FileUpload. + + exit: + Route to redirect to when user selectes exit action. + + """ + def route(controller: Callable) -> Callable: + """Decorate blueprint route so that it wrapps the controller with + workflow redirection.""" + # controler gets 'updated' to look like wrapper but keeps + # name and docstr + # https://docs.python.org/2/library/functools.html#functools.wraps + @wraps(controller) + def wrapper(submission_id: str) -> Response: + """Update the redirect to the next, previous, or exit page.""" + + action = request.form.get('action', None) + submission, _ = load_submission(submission_id) + workflow = request.workflow + this_stage = blueprint_this_stage or \ + workflow.workflow[endpoint_name()] + + # convert classes, ints and strs to actual instances + this_stage = workflow.workflow[this_stage] + + if workflow.is_complete() and not endpoint_name() == workflow.workflow.confirmation.endpoint: + return to_stage(workflow.workflow.confirmation, submission_id) + + if not workflow.can_proceed_to(this_stage): + logger.debug(f'sub {submission_id} cannot proceed to {this_stage}') + return to_current(workflow, submission_id) + + # If the user selects "go back", we attempt to save their input + # above. But if the input does not validate, we don't prevent them + # from going to the previous step. + try: + data, code, headers, resp_fn = controller(submission_id) + #WARNING: controllers do not update the ui-app in this scope + except BadRequest: + if action == PREVIOUS: + return to_previous(workflow, this_stage, submission_id) + raise + + workflow.mark_seen(this_stage) + put_seen(workflow.seen) + + last_stage = workflow.workflow.order[-1] == this_stage + controller_action = get_controllers_desire(data) + flow_desc = flow_decision(request.method, action, code, + controller_action, last_stage) + logger.debug(f'method: {request.method} action: {action}, code: {code}, ' + f'controller action: {controller_action}, last_stage: {last_stage}') + logger.debug(f'flow decisions is {flow_desc}') + + if flow_desc == 'REDIRECT_CONFIRMATION': + return to_stage(workflow.workflow.confirmation, submission_id) + if flow_desc == 'SHOW_CONTROLLER_RESULT': + return resp_fn() + if flow_desc == 'REDIRECT_EXIT': + return redirect(url_for(exit), code=status.SEE_OTHER) + if flow_desc == 'REDIRECT_NEXT': + return to_next(workflow, this_stage, submission_id) + if flow_desc == 'REDIRECT_PREVIOUS': + return to_previous(workflow, this_stage, submission_id) + if flow_desc == 'REDIRECT_PARENT_STAGE': + return to_stage(this_stage, submission_id) + else: + raise ValueError(f'flow_desc must be of type FlowDecision but was {flow_desc}') + + return wrapper + return route + + +def flow_decision(method: str, + user_action: Optional[str], + code: int, + controller_action: Optional[ControllerDesires], + last_stage: bool)\ + -> FlowDecision: + # For now with GET we do the same sort of things + if method == 'GET' and controller_action == STAGE_CURRENT: + return 'REDIRECT_NEXT' + if (method == 'GET' and code == 200) or \ + (method == 'GET' and controller_action == STAGE_RESHOW): + return 'SHOW_CONTROLLER_RESULT' + if method == 'GET' and code != status.OK: + return 'SHOW_CONTROLLER_RESULT' # some sort of error? + + if method != 'POST': + return 'SHOW_CONTROLLER_RESULT' # Not sure, HEAD? PUT? + + # after this point method must be POST + if controller_action == STAGE_SUCCESS: + if last_stage: + return 'REDIRECT_CONFIRMATION' + if user_action == NEXT: + return 'REDIRECT_NEXT' + if user_action == SAVE_EXIT: + return 'REDIRECT_EXIT' + if user_action == PREVIOUS: + return 'REDIRECT_PREVIOUS' + if user_action is None: # like cross_list with action ADD? + return 'SHOW_CONTROLLER_RESULT' + + if controller_action == STAGE_RESHOW: + if user_action == NEXT: + # Reshow the form to the due to form errors + return 'SHOW_CONTROLLER_RESULT' + if user_action == PREVIOUS: + # User wants to go back but there are errors on the form + # We ignore the errors and go back. The user's form input + # is probably lost. + return 'REDIRECT_PREVIOUS' + if user_action == SAVE_EXIT: + return 'REDIRECT_EXIT' + + if controller_action == STAGE_PARENT: + # This is what we get from a sub-form like upload_delete.delete_file + # on success. Redirect to parent stage. + return 'REDIRECT_PARENT_STAGE' + + # These are the same as if the controller_action was STAGE_SUCCESS + # Not sure if that is a good thing or a bad thing. + if user_action == NEXT: + return 'REDIRECT_NEXT' + if user_action == SAVE_EXIT: + return 'REDIRECT_EXIT' + if user_action == PREVIOUS: + return 'REDIRECT_PREVIOUS' + # default to what? + return 'SHOW_CONTROLLER_RESULT' diff --git a/submit/routes/ui/ui.py b/submit/routes/ui/ui.py new file mode 100644 index 0000000..7eb69e1 --- /dev/null +++ b/submit/routes/ui/ui.py @@ -0,0 +1,558 @@ +"""Provides routes for the ui-app user interface.""" + +from http import HTTPStatus as status +from typing import Optional, Callable, Dict, List, Union, Any + +from arxiv_auth import auth +from flask import Blueprint, make_response, redirect, request, Markup, \ + render_template, url_for, g, send_file, session +from flask import Response as FResponse +from werkzeug.datastructures import MultiDict +from werkzeug import Response as WResponse +from werkzeug.exceptions import InternalServerError, BadRequest, \ + ServiceUnavailable + +import arxiv.submission as events +from arxiv import taxonomy +from arxiv.base import logging, alerts +from arxiv.submission.domain import Submission +from arxiv.submission.services.classic.exceptions import Unavailable + +from ..auth import is_owner +from submit import util +from submit.controllers import ui as cntrls +from submit.controllers.ui.new import upload +from submit.controllers.ui.new import upload_delete + +from submit.workflow.stages import FileUpload + +from submit.workflow import SubmissionWorkflow, ReplacementWorkflow, Stage +from submit.workflow.processor import WorkflowProcessor + +from .flow_control import flow_control, get_workflow, endpoint_name + +logger = logging.getLogger(__name__) + +UI = Blueprint('ui', __name__, url_prefix='/') + +SUPPORT = Markup( + 'If you continue to experience problems, please contact' + ' arXiv support.' +) + +Response = Union[FResponse, WResponse] + + +def redirect_to_login(*args, **kwargs) -> str: + """Send the unauthorized user to the log in page.""" + return redirect(url_for('login')) + + +@UI.before_request +def load_submission() -> None: + """Load the ui-app before the request is processed.""" + if request.view_args is None or 'submission_id' not in request.view_args: + return + submission_id = request.view_args['submission_id'] + try: + request.submission, request.events = \ + util.load_submission(submission_id) + except Unavailable as e: + raise ServiceUnavailable('Could not connect to database') from e + + wfp = get_workflow(request.submission) + request.workflow = wfp + request.current_stage = wfp.current_stage() + request.this_stage = wfp.workflow[endpoint_name()] + + +@UI.context_processor +def inject_workflow() -> Dict[str, Optional[WorkflowProcessor]]: + """Inject the current workflow into the template rendering context.""" + rd = {} + if hasattr(request, 'workflow'): + rd['workflow'] = request.workflow + if hasattr(request, 'current_stage'): + rd['get_current_stage_for_submission'] = request.current_stage + if hasattr(request, 'this_stage'): + rd['this_stage'] = request.this_stage + return rd + + # TODO below is unexpected: why are we setting this to a function? + return {'workflow': None, 'get_workflow': get_workflow} + + +def add_immediate_alert(context: dict, severity: str, + message: Union[str, dict], title: Optional[str] = None, + dismissable: bool = True, safe: bool = False) -> None: + """Add an alert for immediate display.""" + if safe and isinstance(message, str): + message = Markup(message) + data = {'message': message, 'title': title, 'dismissable': dismissable} + + if 'immediate_alerts' not in context: + context['immediate_alerts'] = [] + context['immediate_alerts'].append((severity, data)) + + +def handle(controller: Callable, template: str, title: str, + submission_id: Optional[int] = None, + get_params: bool = False, flow_controlled: bool = False, + **kwargs: Any) -> Response: + """ + Generalized request handling pattern. + + Parameters + ---------- + controller : callable + A controller function with the signature ``(method: str, params: + MultiDict, session: Session, submission_id: int, token: str) -> + Tuple[dict, int, dict]`` + template : str + HTML template to use in the response. + title : str + Page title, if not provided by controller. + submission_id : int or None + get_params : bool + If True, GET parameters will be passed to the controller on GET + requests. Default is False. + kwargs : kwargs + Passed as ``**kwargs`` to the controller. + + Returns + ------- + :class:`.Response` + + """ + response: Response + logger.debug('Handle call to controller %s with template %s, title %s,' + ' and ID %s', controller, template, title, submission_id) + if request.method == 'GET' and get_params: + request_data = MultiDict(request.args.items(multi=True)) + else: + request_data = MultiDict(request.form.items(multi=True)) + + context = {'pagetitle': title} + + data, code, headers = controller(request.method, request_data, + request.auth, submission_id, + **kwargs) + context.update(data) + + if flow_controlled: + return (data, code, headers, + lambda: make_response(render_template(template, **context), code)) + if code < 300: + response = make_response(render_template(template, **context), code) + elif 'Location' in headers: + response = redirect(headers['Location'], code=code) + else: + response = FResponse(response=context, status=code, headers=headers) + return response + + +@UI.route('/status', methods=['GET']) +def service_status(): + """Status endpoint.""" + return 'ok' + + +@UI.route('/', methods=["GET"]) +@auth.decorators.scoped(auth.scopes.CREATE_SUBMISSION, + unauthorized=redirect_to_login) +def manage_submissions(): + """Display the ui-app management dashboard.""" + return handle(cntrls.create, 'submit/manage_submissions.html', + 'Manage submissions') + + +@UI.route('/', methods=["POST"]) +@auth.decorators.scoped(auth.scopes.CREATE_SUBMISSION, + unauthorized=redirect_to_login) +def create_submission(): + """Create a new ui-app.""" + return handle(cntrls.create, 'submit/manage_submissions.html', + 'Create a new ui-app') + + +@UI.route('//unsubmit', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def unsubmit_submission(submission_id: int): + """Unsubmit (unfinalize) a ui-app.""" + return handle(cntrls.new.unsubmit.unsubmit, + 'submit/confirm_unsubmit.html', + 'Unsubmit ui-app', submission_id) + + +@UI.route('//delete', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.DELETE_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def delete_submission(submission_id: int): + """Delete, or roll a ui-app back to the last announced state.""" + return handle(cntrls.delete.delete, + 'submit/confirm_delete_submission.html', + 'Delete ui-app or replacement', submission_id) + + +@UI.route('//cancel/', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def cancel_request(submission_id: int, request_id: str): + """Cancel a pending request.""" + return handle(cntrls.delete.cancel_request, + 'submit/confirm_cancel_request.html', 'Cancel request', + submission_id, request_id=request_id) + + +@UI.route('//replace', methods=["POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def create_replacement(submission_id: int): + """Create a replacement ui-app.""" + return handle(cntrls.new.create.replace, 'submit/replace.html', + 'Create a new version (replacement)', submission_id) + + +@UI.route('/', methods=["GET"]) +@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def submission_status(submission_id: int) -> Response: + """Display the current state of the ui-app.""" + return handle(cntrls.submission_status, 'submit/status.html', + 'Submission status', submission_id) + + +@UI.route('//edit', methods=['GET']) +@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def submission_edit(submission_id: int) -> Response: + """Redirects to current edit stage of the ui-app.""" + return handle(cntrls.submission_edit, 'submit/status.html', + 'Submission status', submission_id, flow_controlled=True) + +# # TODO: remove me!! +# @UI.route(path('announce'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def announce(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.announce_submission(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) +# +# +# # TODO: remove me!! +# @UI.route(path('/place_on_hold'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def place_on_hold(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.place_on_hold(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) +# +# +# # TODO: remove me!! +# @UI.route(path('apply_cross'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def apply_cross(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.apply_cross(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) +# +# +# # TODO: remove me!! +# @UI.route(path('reject_cross'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def reject_cross(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.reject_cross(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) +# +# +# # TODO: remove me!! +# @UI.route(path('apply_withdrawal'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def apply_withdrawal(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.apply_withdrawal(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) +# +# +# # TODO: remove me!! +# @UI.route(path('reject_withdrawal'), methods=["GET"]) +# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) +# def reject_withdrawal(submission_id: int) -> Response: +# """WARNING WARNING WARNING this is for testing purposes only.""" +# util.reject_withdrawal(submission_id) +# target = url_for('ui.submission_status', submission_id=submission_id) +# return Response(response={}, status=status.SEE_OTHER, +# headers={'Location': target}) + + +@UI.route('//verify_user', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def verify_user(submission_id: Optional[int] = None) -> Response: + """Render the submit start page.""" + return handle(cntrls.verify, 'submit/verify_user.html', + 'Verify User Information', submission_id, flow_controlled=True) + + +@UI.route('//authorship', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def authorship(submission_id: int) -> Response: + """Render step 2, authorship.""" + return handle(cntrls.authorship, 'submit/authorship.html', + 'Confirm Authorship', submission_id, flow_controlled=True) + + +@UI.route('//license', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def license(submission_id: int) -> Response: + """Render step 3, select license.""" + return handle(cntrls.license, 'submit/license.html', + 'Select a License', submission_id, flow_controlled=True) + + +@UI.route('//policy', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def policy(submission_id: int) -> Response: + """Render step 4, policy agreement.""" + return handle(cntrls.policy, 'submit/policy.html', + 'Acknowledge Policy Statement', submission_id, + flow_controlled=True) + + +@UI.route('//classification', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def classification(submission_id: int) -> Response: + """Render step 5, choose classification.""" + return handle(cntrls.classification, + 'submit/classification.html', + 'Choose a Primary Classification', submission_id, + flow_controlled=True) + + +@UI.route('//cross_list', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def cross_list(submission_id: int) -> Response: + """Render step 6, secondary classes.""" + return handle(cntrls.cross_list, + 'submit/cross_list.html', + 'Choose Cross-List Classifications', submission_id, + flow_controlled=True) + + +@UI.route('//file_upload', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def file_upload(submission_id: int) -> Response: + """Render step 7, file upload.""" + return handle(upload.upload_files, 'submit/file_upload.html', + 'Upload Files', submission_id, files=request.files, + token=request.environ['token'], flow_controlled=True) + + +@UI.route('//file_delete', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control(FileUpload) +def file_delete(submission_id: int) -> Response: + """Provide the file deletion endpoint, part of the upload step.""" + return handle(upload_delete.delete_file, 'submit/confirm_delete.html', + 'Delete File', submission_id, get_params=True, + token=request.environ['token'], flow_controlled=True) + + +@UI.route('//file_delete_all', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control(FileUpload) +def file_delete_all(submission_id: int) -> Response: + """Provide endpoint to delete all files, part of the upload step.""" + return handle(upload_delete.delete_all, + 'submit/confirm_delete_all.html', 'Delete All Files', + submission_id, get_params=True, + token=request.environ['token'], flow_controlled=True) + + +@UI.route('//file_process', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def file_process(submission_id: int) -> Response: + """Render step 8, file processing.""" + return handle(cntrls.process.file_process, 'submit/file_process.html', + 'Process Files', submission_id, get_params=True, + token=request.environ['token'], flow_controlled=True) + + +@UI.route('//preview.pdf', methods=["GET"]) +@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +# TODO @flow_control(Process)? +def file_preview(submission_id: int) -> Response: + data, code, headers = cntrls.new.process.file_preview( + MultiDict(request.args.items(multi=True)), + request.auth, + submission_id, + request.environ['token'] + ) + rv = send_file(data, mimetype=headers['Content-Type'], cache_timeout=0) + rv.set_etag(headers['ETag']) + rv.headers['Content-Length'] = len(data) # type: ignore + rv.headers['Cache-Control'] = 'no-store' + return rv + + +@UI.route('//compilation_log', methods=["GET"]) +@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +# TODO @flow_control(Process) ? +def compilation_log(submission_id: int) -> Response: + data, code, headers = cntrls.process.compilation_log( + MultiDict(request.args.items(multi=True)), + request.auth, + submission_id, + request.environ['token'] + ) + rv = send_file(data, mimetype=headers['Content-Type'], cache_timeout=0) + rv.set_etag(headers['ETag']) + rv.headers['Cache-Control'] = 'no-store' + return rv + + +@UI.route('//add_metadata', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def add_metadata(submission_id: int) -> Response: + """Render step 9, metadata.""" + return handle(cntrls.metadata, 'submit/add_metadata.html', + 'Add or Edit Metadata', submission_id, flow_controlled=True) + + +@UI.route('//add_optional_metadata', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def add_optional_metadata(submission_id: int) -> Response: + """Render step 9, metadata.""" + return handle(cntrls.optional, + 'submit/add_optional_metadata.html', + 'Add or Edit Metadata', submission_id, flow_controlled=True) + + +@UI.route('//final_preview', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def final_preview(submission_id: int) -> Response: + """Render step 10, preview.""" + return handle(cntrls.finalize, 'submit/final_preview.html', + 'Preview and Approve', submission_id, flow_controlled=True) + + +@UI.route('//confirmation', methods=['GET', 'POST']) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def confirmation(submission_id: int) -> Response: + """Render the final confirmation page.""" + return handle(cntrls.new.final.confirm, "submit/confirm_submit.html", + 'Submission Confirmed', + submission_id, flow_controlled=True) + +# Other workflows. + + +# Jref is a single controller and not a workflow +@UI.route('//jref', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def jref(submission_id: Optional[int] = None) -> Response: + """Render the JREF ui-app page.""" + return handle(cntrls.jref.jref, 'submit/jref.html', + 'Add journal reference', submission_id, + flow_controlled=False) + + +@UI.route('//withdraw', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def withdraw(submission_id: Optional[int] = None) -> Response: + """Render the withdrawal request page.""" + return handle(cntrls.withdraw.request_withdrawal, + 'submit/withdraw.html', 'Request withdrawal', + submission_id, flow_controlled=False) + + +@UI.route('//request_cross', methods=["GET", "POST"]) +@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +@flow_control() +def request_cross(submission_id: Optional[int] = None) -> Response: + """Render the cross-list request page.""" + return handle(cntrls.cross.request_cross, + 'submit/request_cross_list.html', 'Request cross-list', + submission_id, flow_controlled=True) + +@UI.route('/testalerts') +def testalerts() -> Response: + tc = {} + request.submission, request.events = util.load_submission(1) + wfp = get_workflow(request.submission) + request.workflow = wfp + request.current_stage = wfp.current_stage() + request.this_stage = wfp.workflow[endpoint_name()] + + tc['workflow'] = wfp + tc['submission_id'] = 1 + + add_immediate_alert(tc, 'WARNING', 'This is a warning to you from the normal ui-app alert system.', "SUBMISSION ALERT TITLE") + alerts.flash_failure('This is one of those alerts from base alert(): you failed', 'BASE ALERT') + return make_response(render_template('submit/testalerts.html', **tc), 200) + + +@UI.app_template_filter() +def endorsetype(endorsements: List[str]) -> str: + """ + Transmit endorsement status to template for message filtering. + + Parameters + ---------- + endorsements : list + The list of categories (str IDs) for which the user is endorsed. + + Returns + ------- + str + For now. + + """ + if len(endorsements) == 0: + return 'None' + elif '*.*' in endorsements: + return 'All' + return 'Some' diff --git a/submit/services/__init__.py b/submit/services/__init__.py new file mode 100644 index 0000000..6c2968e --- /dev/null +++ b/submit/services/__init__.py @@ -0,0 +1,2 @@ +"""External service integrations.""" + diff --git a/submit/static/css/manage_submissions.css b/submit/static/css/manage_submissions.css new file mode 100644 index 0000000..b9c827e --- /dev/null +++ b/submit/static/css/manage_submissions.css @@ -0,0 +1,89 @@ +@media only screen and (max-width: 320px) { + .box { + padding: unset; + padding-bottom: 0.75em; } } +@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { + table { + display: block; } + + thead { + display: block; } + thead tr { + position: absolute; + top: -9999px; + left: -9999px; } + + tbody { + display: block; } + + th { + display: block; } + + td { + display: block; + border: none; + position: relative; + padding-left: 30%; } + td:before { + position: absolute; + top: 6px; + left: 6px; + width: 45%; + padding-right: 10px; + white-space: nowrap; } + td.user-submission:nth-of-type(1):before { + content: "Status"; + font-weight: bold; } + td.user-submission:nth-of-type(2):before { + content: "Identifier"; + font-weight: bold; } + td.user-submission:nth-of-type(3):before { + content: "Title"; + font-weight: bold; } + td.user-submission:nth-of-type(4):before { + content: "Created"; + font-weight: bold; } + td.user-submission:nth-of-type(5):before { + content: "Actions"; + font-weight: bold; } + td.user-announced:nth-of-type(1):before { + content: "Identifier"; + font-weight: bold; } + td.user-announced:nth-of-type(2):before { + content: "Primary Classification"; + font-weight: bold; } + td.user-announced:nth-of-type(3):before { + content: "Title"; + font-weight: bold; } + td.user-announced:nth-of-type(4):before { + content: "Actions"; + font-weight: bold; } + + tr { + display: block; + border: 1px solid #ccc; } + + .table td { + padding-left: 30%; } + .table th { + padding-left: 30%; } } +.box-new-submission { + padding: 2rem important; } + +.button-create-submission { + margin-bottom: 1.5rem; } + +.button-delete-submission { + border: 0px; + text-decoration: none; } + +/* Welcome message -- "alpha" box */ +/* This is the subtitle text in the "alpha" box. */ +.subtitle-intro { + margin-left: 1.25em; } + +.alpha-before { + position: relative; + margin-left: 2em; } + +/*# sourceMappingURL=manage_submissions.css.map */ diff --git a/submit/static/css/manage_submissions.css.map b/submit/static/css/manage_submissions.css.map new file mode 100644 index 0000000..6986389 --- /dev/null +++ b/submit/static/css/manage_submissions.css.map @@ -0,0 +1,7 @@ +{ +"version": 3, +"mappings": "AAGA,yCAAyC;EACvC,IAAI;IACF,OAAO,EAAE,KAAK;IACd,cAAc,EAAE,MAAM;AAE1B,mGAAgG;EAC9F,KAAK;IACH,OAAO,EAAE,KAAK;;EAEhB,KAAK;IACH,OAAO,EAAE,KAAK;IACd,QAAE;MACA,QAAQ,EAAE,QAAQ;MAClB,GAAG,EAAE,OAAO;MACZ,IAAI,EAAE,OAAO;;EAGjB,KAAK;IACH,OAAO,EAAE,KAAK;;EAEhB,EAAE;IACA,OAAO,EAAE,KAAK;;EAEhB,EAAE;IACA,OAAO,EAAE,KAAK;IACd,MAAM,EAAE,IAAI;IACZ,QAAQ,EAAE,QAAQ;IAClB,YAAY,EAAE,GAAG;IACjB,SAAQ;MACN,QAAQ,EAAE,QAAQ;MAClB,GAAG,EAAE,GAAG;MACR,IAAI,EAAE,GAAG;MACT,KAAK,EAAE,GAAG;MACV,aAAa,EAAE,IAAI;MACnB,WAAW,EAAE,MAAM;IAGnB,wCAAuB;MACrB,OAAO,EAAE,QAAQ;MACjB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,YAAY;MACrB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,OAAO;MAChB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;IAInB,uCAAuB;MACrB,OAAO,EAAE,YAAY;MACrB,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,wBAAwB;MACjC,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,OAAO;MAChB,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;;EAIvB,EAAE;IACA,OAAO,EAAE,KAAK;IACd,MAAM,EAAE,cAA+B;;EAGvC,SAAE;IACA,YAAY,EAAE,GAAG;EAEnB,SAAE;IACA,YAAY,EAAE,GAAG;AAGvB,mBAAmB;EACjB,OAAO,EAAE,cAAc;;AAEzB,yBAAyB;EACvB,aAAa,EAAE,MAAM;;AAEvB,yBAAyB;EACvB,MAAM,EAAE,GAAG;EACX,eAAe,EAAE,IAAI;;;;AAMvB,eAAe;EACb,WAAW,EAAE,MAAM;;AAErB,aAAa;EACX,QAAQ,EAAE,QAAQ;EAClB,WAAW,EAAE,GAAG", +"sources": ["../sass/manage_submissions.sass"], +"names": [], +"file": "manage_submissions.css" +} \ No newline at end of file diff --git a/submit/static/css/submit.css b/submit/static/css/submit.css new file mode 100644 index 0000000..7ae6c88 --- /dev/null +++ b/submit/static/css/submit.css @@ -0,0 +1,551 @@ +@charset "UTF-8"; +/* Cuts down on unnecessary whitespace at top. Consider move to base. */ +main { + padding: 1rem 1.5rem; +} + +.section { + /* move this to base as $section-padding */ + padding: 1em 0; +} + +.button, p .button { + font-family: "Open Sans", "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; +} +.button.is-short, p .button.is-short { + height: 1.75em; +} +.button svg.icon, p .button svg.icon { + top: 0; + font-size: 0.9em; +} + +.button.reprocess { + margin-top: 0.5em; +} + +/* Allows text in a button to wrap responsively. */ +.button-is-wrappable { + height: 100%; + white-space: normal; +} + +.button-feedback { + vertical-align: baseline; +} + +.submit-nav { + margin-top: 1.75rem; + margin-bottom: 2em !important; + justify-content: flex-end; +} +.submit-nav .button .icon { + margin-right: 0 !important; +} + +/* controls display of close button for messages */ +.message button.notification-dismiss { + position: relative; + z-index: 1; + top: 30px; + margin-left: calc(100% - 30px); +} + +.form-margin { + margin: 0.4em 0; +} + +.notifications { + margin-bottom: 1em; +} + +.policy-scroll { + margin: 0; + margin-bottom: -1em; + padding: 1em 3em; + max-height: 400px; + overflow-y: scroll; +} +@media screen and (max-width: 768px) { + .policy-scroll { + padding: 1em; + } +} +@media screen and (max-width: 400px) { + .policy-scroll { + max-height: 250px; + } +} + +/* forces display of scrollbar in webkit browsers */ +.policy-scroll::-webkit-scrollbar { + -webkit-appearance: none; + width: 7px; +} + +.policy-scroll::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); +} + +nav.submit-pagination { + display: block; + /*float: right*/ + width: 100%; +} + +h2 .title-submit { + margin-bottom: 0em; + margin-top: 0.25em !important; + display: block; + float: left; + width: 10%; + display: none; + color: #005e9d; +} +h2 .replacement { + margin-top: -2rem; + margin-bottom: 2rem; + font-style: italic; + font-weight: 400; +} + +h1.title.title-submit { + margin-top: 0.75em; + margin-bottom: 0.5em; + clear: both; + color: #005e9d !important; +} +h1.title.title-submit .preamble { + color: #7f8d93; +} +@media screen and (max-width: 768px) { + h1.title.title-submit .title.title-submit { + margin-top: 0; + } +} + +.texlive-summary article { + padding: 0.8rem; +} +.texlive-summary article:not(:last-child) { + margin-bottom: 0.5em; + border-bottom-width: 1px; + border-bottom-style: dotted; +} + +.alpha-before::before { + content: "α"; + color: rgba(204, 204, 204, 0.5); + font-size: 10em; + position: absolute; + top: -0.4em; + left: -0.25em; + line-height: 1; + font-family: serif; +} + +.beta-before::before { + content: "β"; + color: rgba(204, 204, 204, 0.5); + font-size: 10em; + position: absolute; + top: -0.4em; + left: -0.25em; + line-height: 1; + font-family: serif; +} + +/* overrides for this form only? may move to base */ +.field:not(:last-child) { + margin-bottom: 1.25em; +} + +.label:not(:last-child) { + margin-bottom: 0.25em; +} + +.is-horizontal { + border-bottom: 1px solid whitesmoke; +} + +.is-horizontal:last-of-type { + border-bottom: 0px; +} + +.buttons .button:not(:last-child) { + margin-right: 0.75rem; +} + +.content-container, .action-container { + border: 1px solid #d3e1e6; + padding: 1em; + margin: 0 !important; +} + +.content-container { + border-bottom: 0px; +} + +.action-container { + box-shadow: 0 0 9px 0 rgba(210, 210, 210, 0.5); +} +.action-container .upload-notes { + border-bottom: 1px solid #d3e1e6; + padding-bottom: 1em; +} + +/* abs preview styles */ +.content-container #abs { + margin: 1em 2em; +} +.content-container #abs .title { + font-weight: 700; + font-size: x-large; +} +.content-container #abs blockquote.abstract { + font-size: 1.15rem; + margin: 1em 2em 2em 4em; +} +.content-container #abs .authors { + margin-top: 20px; + margin-bottom: 0; +} +.content-container #abs .dateline { + text-transform: uppercase; + font-style: normal; + font-size: 0.8rem; + margin-top: 2px; +} +@media screen and (max-width: 768px) { + .content-container #abs blockquote.abstract { + margin: 1em; + } +} + +/* category styles on cross-list page */ +.category-list .action-container { + padding: 0; +} +.category-list form { + margin: 0; +} +.category-list form li { + margin: 0; + padding-top: 0.25em !important; + padding-bottom: 0.25em !important; + display: flex; +} +.category-list form li button { + padding-top: 0 !important; + margin: 0; + height: 1em; +} +.category-list form li span.category-name-label { + flex-grow: 1; +} + +.cross-list-area { + float: right; + width: 40%; + border-left: 1px solid #f2f2f2; +} +@media screen and (max-width: 768px) { + .cross-list-area { + width: 100%; + border-bottom: 1px solid #f2f2f2; + border-left: 0px; + } +} + +.box-new-submission { + text-align: left; +} +.box-new-submission .button { + margin: 0 0 1em 0; +} + +/* tightens up user data display on verify user page */ +.user-info { + margin-bottom: 1rem; +} +.user-info .field { + margin-bottom: 0.5rem; +} +.user-info .field .field-label { + flex-grow: 2; +} +.user-info .field span.field-note { + font-style: italic; +} + +.control .radio { + margin-bottom: 0.25em; +} + +/* Classes for zebra striping and nested directory display */ +ol.file-tree { + margin: 0 0 1rem 0; + list-style-type: none; +} +ol.file-tree li { + margin-top: 0; + padding-top: 0.15em; + padding-bottom: 0.15em; + padding-left: 0.25em; + padding-right: 0.25em; + background-color: white; +} +ol.file-tree li.even { + background-color: #E8E8E8; +} +ol.file-tree li button { + vertical-align: baseline; +} +ol.file-tree form:nth-of-type(odd) li { + background-color: #E8E8E8; +} +ol.file-tree .columns { + margin: 0; +} +ol.file-tree .column { + padding: 0; +} +ol.file-tree .column .columns { + margin: 0; +} +ol.file-tree > li ol { + border-left: 2px solid #CCC; + margin: 0 0 0 1rem; +} +ol.file-tree > li ol li { + padding-left: 1em; +} +ol.file-tree > li ol li:first-child { + margin-top: 0; +} + +/* For highlighting TeX logs */ +.highlight-key { + border-top: 1px solid #d3e1e6; + border-bottom: 1px solid #d3e1e6; + padding: 1em 0 2em 0; +} + +.tex-help { + background-color: rgba(28, 97, 246, 0.4); + /* background-color: rgba(152, 35, 255, 0.40) */ + color: #082031; + padding: 0 0.2em; +} + +.tex-suggestion { + background-color: rgba(255, 245, 35, 0.6); + color: #082031; + padding: 0 0.2em; +} + +.tex-info { + background-color: rgba(0, 104, 173, 0.15); + color: #082031; + padding: 0 0.2em; +} + +.tex-success { + background-color: rgba(60, 181, 33, 0.15); + color: #092c01; + padding: 0 0.2em; +} + +.tex-ignore { + background-color: rgba(183, 183, 183, 0.3); + color: #092c01; + padding: 0 0.2em; +} + +.tex-warning { + background-color: rgba(214, 118, 0, 0.4); + color: #211608; + padding: 0 0.2em; +} + +.tex-danger { + background-color: rgba(204, 3, 0, 0.3); + color: #340f0e; + padding: 0 0.2em; +} + +.tex-fatal { + background-color: #cd0200; + color: white; + padding: 0 0.2em; +} + +/* Classes for condensed, responsive progress bar */ +.progressbar, +.progressbar li a { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + margin: 0; +} + +.progressbar { + /*default styles*/ + /*complete styles*/ + /*active styles*/ + /*controls number of links per row when screen is too narrow for just one row*/ +} +.progressbar li { + background-color: #1c8bd6; + list-style: none; + padding: 0; + margin: 0 0.5px; + transition: 0.3s; + border: 0; + flex-grow: 1; +} +.progressbar li a { + font-weight: 600; + text-decoration: none; + color: #f5fbff; + padding-left: 1em; + padding-right: 1em; + padding-top: 0px; + padding-bottom: 0px; + transition: color 0.3s; + height: 25px; +} +.progressbar li a:hover, +.progressbar li a:focus, +.progressbar li a:active { + text-decoration: none; + border-bottom: 0px; + color: #032121; +} +.progressbar li.is-complete { + background-color: #e9f0f5; +} +.progressbar li.is-complete a { + color: #535E62; + text-decoration: underline; + font-weight: 300; + transition: 0.3s; +} +.progressbar li.is-complete a:hover { + height: 30px; +} +.progressbar li.is-active, +.progressbar li.is-complete.is-active { + background-color: #005e9d; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.25); +} +.progressbar li.is-active a, +.progressbar li.is-complete.is-active a { + color: #f5fbff; + font-weight: 600; + cursor: default; + pointer-events: none; + height: 30px; +} +.progressbar li.is-active a { + text-decoration: none; +} +@media screen and (max-width: 1100px) { + .progressbar li { + width: calc(100% * (1/6) - 10px - 1px); + } +} + +/* info and help bubbles */ +.help-bubble { + position: relative; + display: inline-block; +} +.help-bubble:hover .bubble-text { + visibility: visible; +} +.help-bubble:focus .bubble-text { + visibility: visible; +} +.help-bubble .bubble-text { + position: absolute; + visibility: hidden; + width: 160px; + background-color: #F5FAFE; + border: 1px solid #0068AC; + color: #0068AC; + text-align: center; + padding: 5px; + border-radius: 4px; + bottom: 125%; + left: 50%; + margin-left: -80px; + font-size: 0.8rem; +} +.help-bubble .bubble-text:after { + content: " "; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #0068AC transparent transparent transparent; +} + +/* Formats the autotex log ouput. + * + * Ideally this would also have white-space: nowrap; but it doesnt play nice + * with flex. Unfortunately, the widely accepted solution + * (https://css-tricks.com/flexbox-truncated-text/) is not working here. */ +.log-output { + max-height: 50vh; + height: 50vh; + color: black; + overflow-y: scroll; + overflow-x: scroll; + max-height: 100%; + max-width: 100%; + background-color: whitesmoke; + font-family: "Courier New", Courier, monospace; + font-size: 9pt; + padding: 4px; +} + +/* See https://github.com/jgthms/bulma/issues/1417 */ +.level-is-shrinkable { + flex-shrink: 1; +} + +/* Prevent the links under the mini search bar from wrapping. */ +.mini-search .help { + width: 200%; +} + +/* This forces the content to stay in bounds. This also fixes the overflow + * of selects (without breaking the mini-search bar). */ +.container { + width: 100%; +} + +columns { + max-width: 100%; +} + +column { + max-width: 100%; +} + +.field { + max-width: 100%; +} + +.control-cross-list { + max-width: 80%; +} + +/*# sourceMappingURL=submit.css.map */ diff --git a/submit/static/css/submit.css.map b/submit/static/css/submit.css.map new file mode 100644 index 0000000..2ebd532 --- /dev/null +++ b/submit/static/css/submit.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../sass/submit.sass"],"names":[],"mappings":";AAAA;AACA;EACE;;;AAEF;AACE;EACA;;;AAEF;EACE;EACA;;AACA;EACE;;AACF;EACE;EACA;;;AACJ;EACE;;;AACF;AACA;EACE;EACA;;;AACF;EACE;;;AAEF;EACE;EACA;EACA;;AAEE;EACE;;;AACN;AAEE;EACE;EACA;EACA;EACA;;;AAEJ;EACE;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;EACA;EACA;;AACA;EANF;IAOI;;;AACF;EARF;IASI;;;;AACJ;AACA;EACE;EACA;;;AACF;EACE;EACA;EACA;;;AAEF;EACE;AACA;EACA;;;AAGA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;EACE;EACA;EACA;EACA;;;AAEJ;EACE;EACA;EACA;EACA;;AACA;EACE;;AACF;EACE;IACE;;;;AAEN;EACE;;AACA;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEJ;AACA;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AACF;EACE;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;;;AACF;EACE;;;AACF;EACE;;AACA;EACE;EACA;;;AAEJ;AACA;EACE;;AACA;EACE;EACA;;AACF;EACE;EACA;;AACF;EACE;EACA;;AACF;EACE;EACA;EACA;EACA;;AACF;EACE;IACE;;;;AAEN;AAEE;EACE;;AACF;EACE;;AACA;EACE;EACA;EACA;EACA;;AACA;EACE;EACA;EACA;;AACF;EACE;;;AAER;EACE;EACA;EACA;;AAEA;EALF;IAMI;IACA;IACA;;;;AAEJ;EACE;;AACA;EACE;;;AACJ;AACA;EACE;;AACA;EACE;;AACA;EACE;;AACF;EACE;;;AAEN;EACE;;;AAEF;AAIA;EACE;EACA;;AACA;EACE;EACA;EACA;EACA;EACA;EACA;;AACA;EACE,kBAdS;;AAeX;EACE;;AAEF;EACE,kBAnBS;;AAoBb;EACE;;AACF;EACE;;AACA;EACE;;AACJ;EACE;EACA;;AACA;EACE;;AACA;EACE;;;AAER;AACA;EACI;EACA;EACA;;;AACJ;EACE;AACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;AACA;AAAA;EAEE;EACA;EACA;EACA;EACA;;;AAEF;AACE;AA0BA;AAWA;AAeA;;AAnDA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;AAAA;AAAA;EAGE;EACA;EACA;;AAGF;EACE;;AACF;EACE;EACA;EACA;EACA;;AACA;EACE;;AAGJ;AAAA;EAEE;EACA;;AACF;AAAA;EAEE;EACA;EACA;EACA;EACA;;AACF;EACE;;AAGF;EACE;IACE;;;;AAEN;AACA;EACE;EACA;;AAEE;EACE;;AAEF;EACE;;AACJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGN;AAAA;AAAA;AAAA;AAAA;AAKA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;AACA;EACE;;;AAEF;AACA;EACE;;;AAEF;AAAA;AAEA;EACE;;;AACF;EACE;;;AACF;EACE;;;AACF;EACE;;;AACF;EACE","file":"submit.css"} \ No newline at end of file diff --git a/submit/static/images/github_issues_search_box.png b/submit/static/images/github_issues_search_box.png new file mode 100644 index 0000000000000000000000000000000000000000..db9a1208ebede67ed0cd6c7fa4472ec0c36106bc GIT binary patch literal 17739 zcmZU41y~$SwkQxBg2NDXIhm^9J~OuYrvCdc`HrIe>vdm$DEM zQIHZ5Ay;s;Gqtcbfq{_>OHfDBP#z%2&{mQ%_eYjQX$@1#l|ji8D^}?gmxM#1>I)(_ zl=z`)e@p>)5MCY*T{}SirKLFA^y&IpLDa-Vhha{^BOdOsY1Ql0YxiMiMZj$2PJUp? z%M50$Xj>}JqYwyl7YC;)EQ)O9sKpa8^%hGI4$lwPiBu10(%&!f=2Q1g`(Wzf7~yYx z0M)F(?n}uYN{}Nzj3lTh8ID_kY$Fr4L*`CN6Bb64tgsqR-LM1?~Ng*fyEMlk=}z1kY4Ry!+0_1EZE&;sWN*97Dq92nferjKr;8 zajQScnj7}O9{b>*VjFO_aeZz@3Zy>@*Gm(IWkZSjG+8jTHy3}!7j4iT%hNf=&l5hU zQhabrdU2`xM5~(4D$a;k&?8*>mRUI?^<7?lr**hk+MEGVGKElb``aELVJXqj6tdI| zDm3}AxxtI=?khtXF+lUQ?ERNNHN_DhlK$_+4_{W|R&cHmW$@M5s zA+EFta2Q7r^wHc~Px^9${hCWP36M3VEo4o{Bs+Dx;g4W1RD5@?VIkxl$0HA74dGJh zpNot{{glcgn3u`<7g>>D0DY3$v2b zR^Ri(@S$pQ&f$o`71;`w;po#$xK0NK{rN9C&s;6B?r|KIyDeeTbAgR>Hs*x%@68<0 z>n0zSQGDcx@23(S$+YQ|xMmTk8rHWvfNCht^BgmSn>wc+%=cC7E2{&18jw;jpD-od2`W!(t?xSwz&x1#UgIO)G7 z*5^CvK)Vgp$3mnI;EIKx=y0Ah!3K}Ad?p%t6Cw1z5T>NVQ{ipBpN&4?7W?Q8;~Ewh zh7ZDSCzbu%CI6BFB+#3uwYTp?v4Voeun`R-HOT8jSvR6@$^9kY;Y58Tw~$1Pi;~1b zV8PT3P$9R9wKS$zig^qo7LBGf`}Spv-~wh}OqYU80X;7ZvGBcRNVEt#~_w_LJ{d844 zrPx-1FPHeGXouY^o1$s{HTD2#0Oh%HTFht{U~TZ+(i^Z6nb}jZhI!`m$m;*)GZh!^ zBQm(lSeV0rBP&utYEFntkP9I*pR|jJ;@hu}3%IT8lS$%3A4aHvG`18R*fH3=Vaq+R zIAO-Jj?@UT8hUa{xgM8cPp@rCJU#n0JE`LWZ< zRSVYIeoLo1gax!ZtJ>5$CM$v2ti8E|N|*dYyhD|PquIv7$pV@2y2Gs=@KVqc}H{X6eIoIck+TJadz z@s1Tgt5_oigR>l=5TW+0wsrSzyl#qa!tU;`Il5lF*wm$ z9K{Y(+lqEk;zS5UC{e$oQXe)jV+g zIp;2CP~wybvWUm-!Xo!M-KuH$Pr$xka-(KY@|1PXJW36}Q@%%~NAw-fCFM>l0eXZW zT2(JkL=HiF4Z5ba8fi(mOWd@bh2?vTqRJ)9c>`{96T1X!Rl{jxm(diORv9xxir??eMagvR|%&#}ITMb5cZm!G`w+(V9q zPS68M=RXcZE=ybP11K&R&N$rDTsqF;>8)wKBdKY;{aAgs(cdDAh@85Ece-3@oh|G= zKL6n6@U$M7`%yL8KOZSW*5J9c?~tj(r*jEzXkKxcbW7b++nCw8o>(48Kh@pznGQU` zKB*eZncnPm-#za-Pwij*W9;7N?sGVQayKxzD6=!YH+^WcnZB-b2ABSyeiUm`qc7>`gnc!w12vhdDy=AvJd}&(uJ}}&Y>?9=6HyjyuwnHd88`b!uisAc=sVaF*t*f0w_Yded?;ND=sVA|lO zyR56Go2cu75=6NpP8{)3R9vh)x}}qS^P=%;={al#cUKXr05&#_^_E?+Z&97p;%daBe?}Ey^X%7aA5EvyUfVyyNlW zYFYNbREtzoH1dhjm{>|felJdqO|cj0#P`c(2;6z6NTqOIq4Cs?D}AD@c_cP4#jAI)c6u`Sd<0aw>B9J(-6?3(txmHD?V+P3Gvj-I@R{ zJlYJ3XE1CRdl!u+tfp0UhAEP9#qbF}LKvMG#4sAF^+-X(VkR@PxCC8FZxrz;@{qgR zdW?Jw#DPeaZ*9q5CX2E1=8a6SSj;lCYaW_i4Q3njZiRZMwA6LL*v>J1BF0<&M2d$< zFoV~Yn$F_o`%U*-?p_yh0r%hzh!%tC*#O9&D9`}O*ly{)BposvU2THVjBWf0$-3&A z21J^hS&7*6tfjDJilv-Y;&j{c z$2n&Yr?Z_tMpJFNdhc7^$EV>kZ0*#;qXW7I>;`;WuNFR&hkY_{pTbpx>Kw!Vg7mm_ zon{=@^wOL1idCB?FOxS5Kh5`+SzSi+$V7atZJaHZ7J96U=g_X{uAiTk`{ZYD#Ez|2 zPixxG%@#AQFh65_=cF8VN_o&0Wz;FNC`(78A+@%dq0WSQI!+8kD=QX^JQ6-1hD{z#N{qs%aXZhP9(6kL z?`n;BrQ5Zwd8~KXWwev*ogJ9B?Ln%p;!gAGM^MrwfJi$0K2(2xSEx6tkLpFV&Uoa>a*)eS(reOthakX z>gva&7lJtuHc3ZMgGUyBS%kbLbgMVb$+$kEi5`fVxR!oVaU^^Wj0DG{`%)&-7dSS2bG`;OkdJ z&bMzN3mEaO2pv_L2bXzDp=Z`-t>K$2HRjm2($8=8f_!oCr+dd<=N2gTlA2C1Fa%V8 zPgp6Xk7qD2uxS=58qOMWvOGq1Hb4VoJ3|woyN&%TGz<*Lo#*w|#>Clx+}+07)``cR zkK$htJg@hEtC=Xs{{`Y~#Ydqbr$8=Z=V(IC4rBvRY0b{ElkY7&rJVI`xO=R zx0Xl2!rjDLL)5~?#MbGR2R|1x8|Yu~|6j|0IsOAv^B)mI~|0ycwCyY#3v{AaeHsoJ=orzrYB#nI*qhqCu&HF zVqw9mFMDxl4<>dY{2%0M0{>9t z?8iHbPt-i&;eDctvi`sP=*WAh$df*P(?<4#9tn7^lpRs&{U5@AIkpudhco`jlqMLI zs8Zj)(N_Kd^jZHFVoOQh(`yq%<@-+rMm)YNf9j#KyQ!%C@=x6ik;61F#{x>=#lRz(?SdgO#98q)@`}>Zs6}t}E8n93 zK#a{Ka;pW96S=%=A~xu<@o%ZMG2io^+0ifBLY$6BdT2401h0|STMROVCw-5M%A3gBEWI0OB*(&m z8+cIK^XujZra&1E9Tc$*V+{jaf>hF482&GlDCBn|+91_onu1a6E8XvU-x8b4Tle-j z)Cz>rd+|B1-3DTzM~l9G7^L^<;dnq<)nb*AK2zy&4{TU5aI}Y=AooelyXhrNuRKNL zkCHOE!O%~aGP*$`3m^Z%N03yk!yaL>hl=s-7FE=b7eX;30qWx4=fGnk4{aT~e*B+w z4&t#sUwce0f0TW9JRw#sR|yLzV59&VY~U0ykaso(-AQMDE1BzW4GWVo=JCMpij!y` z$kqIh^Su`K!Q}c>z}I<+^O-22iw}Dos)Szlv+qsD5YSDIZJNO>2GmkC0-x3yP zXOx&3)sgcjh8Tp&DI*4HAQYeIP2#DvoR%KNX; ziF1*Veru(AQMPG1GI9saR#%dCQXv=IgTfr$a<%A;!5dW4K?Vl5=>-NMt=5*TLp z>O1T2khE{|$2vE6Tl?ap9qwxFazlE>N`Z}6^wI~6|4_7_zh63(E#0)IJxCUe9`QQQ zpwg`*fbWVH6fZXv26tgJ8h`o+xL`fSniP1VaKDyKH8VC0jRA-y243^o*}~3MIwSJ{ z-okV9$IWjz3RcjWz84tHub`vu5%Yy^{T~`Df*pJtUCh8S8r-=J!*P0&y9kim4r4%s z9k~92ABdC;ox6*MhHpnPQCkH6Q?ah_OO!w>A2pNr<%U`av%Q0LS#fBjh;W@TA%mTF zgd&@_N4tIeKtqS~9!cqzgbnz;xPq65Bq z)zWjq$1A`;LT<- zIbVCc2U%i{wL{gw?MqX8@m!f5xw%uYOpGV`uRV2>wfv zepY`Yf%y~)iUx*egz0DQQ_o)clKoap{%l8+L4{N`#zNx(jq+T@RWtx_qBjCC?IPMk zMW<1z@A<~j)+-WqXZhYnnHji;`x(H9jd$`65F5L7-0TdiQY5TNhpRr(a_#20HfC^d zGwQ=CHGGf&i6dcEM5ej@{Ni(q+?#+}>hl55q%DQSBqeyaI=*{}lS&$olhL`c^j=-m zMR{^!%8k!(TGSB;Ck%G9U6mY$hi$;N{|0?5o$@)RGfjQRsngZv@?tF=K4I`?IEI3< zP{ALrm{j70OFF%o&%#hAabRXov3t@2Z9G)b$b55$ap&@epnW6_(JD6#_LD+OcRG+B z+>13?ZQxYBPpVd+#*Xi9hY3Vu_1>tO|119M?n~mIxDZj7VdBC7_*37Rf0!znM?p!cs%Ih|w%WI7iFXW)%lGI?sy(I8Er5K5XYV`VBFC?% zrV`2T)#yT~_gKw8cB-Ps;pei1bj5BK*IhO&vw`+6+-}~!JyhNlpQU$`X$bR;enSaR zl?;^u9xxOYlVh2}gDHUmMXJ4_8=IRoJ$VP6Aptc|!A7r#sJr~Zg>O0idxI5i`B12m z^I%jIZCn{}0pB`wd869gKcr;N()KV3Wi(mm(!7~tYQukL=YfZe`|D^OM!6x92N}R- zZKD5NFQ|XRur4l$lB4&4Wk>Dj^SDvJ`6bh0q3$w|O06We6R61}j>M#Cp0wfO3%6VJ zOsS510-f3#SJQ+oKII)^SjS7-U50MjXn8wB!>T8)%kk2OU&J3hT*+S}8?Qnl3xa=| z|7!>DMIVXTUGp|SlD-Xk6S@qoZwl{BVX=fa&4eTZhC374i`6j;`P+1QBOlev3^rI+ z;-R(Pm5r+x%YBp;yLO1Xi+##a2B0=K`O7<`oo<^-#V7bTBUmrx;ATb5tMJQ-Uv*px zdVdqqSLdKQg-FV*zDTMXjw$nTBQk*yN`Uk^17 zuC3GnhgVyTS}_fy6wkR3aVTk7rg+kDToV>6w58p(z3THjBQP|X%k_vWoy#L`#%@mZ zQqQYfcJ(NOcmIUl~It9sjB zp_=7-Ixpj!+Yc7ZYAY}IKh9dkocyh7g@RHH&>gCLChuTTWL}R zKU;uT0Sd0A3J1)p{#KRHD>uaLGIr1$6}t1i$Y5Bv$DQo3bYnhxQfl}j2jR7r7&<7> zmCf_M%bvq8^D}CjGuk1qaCP0gS>@L8A+@S%k-cQ6BlvY5jBeYcX&~#sH`2Oem!#Vd!YQz#}Xk(Yza$*|D zB?W^RR7jxNVOGPY?S}@4K2HD@KA1l|KOsS1e0P1A#J1hBLq3(qFu^|4((dAkcSsKg zqXEnpuhXp)qI2mI=#@Ux+Z(v_@jYB$-pnySM>ERgxnGIKbgYnqs);nHk9M28s*D;A^cd#IxI)i*~IKJwx|Fh71^UYC? za={lxvFG&8Q@P5k8+EnMn=|TUn++BMcJSRoosUgRGV`582#%%vB3GcSo<0ffn06?19i;?`ay!sed=H>>2zt#xNrszKg(( zmZQy4^LdHP>gj&+Jsu(1Ea9o*UdYrRV4(Hk!#%R0_hS!AUecb|{hoMoa?#;AE=`fd zYG<+5GuY?(VVd*Lg_T{Ab700nFYD0?F1^*PO>QGod;YILTbgKlCP?m8AiqO(eqE+~ z8c)2BZoAa7?%4zbQy0wkX#yv~_=ryPafQ;8C(6|Gx|&M9bJ{HHr_)8(qud~#x};E^ zezvR^)1i#rBu?b{_+?VJp@(Hm{;Y;Xpt8wG3QtD#MQ*>$BP2V)Rp)(V!*gr%Lz=x1 z&y)cn6X`jTe47RWn)%Yy7NTdik#=y!)%%gFq5EMuLd9l|mt5M>(Y)jKlRuJQ&lhv7 zXqujxW<8c}+_L)1D%h@>gUEhk-dRz!h2x7wS;mfWyOG zqeG0+usQ*fv@!yd!ca<=1UwMybtL3BSiJ28E}uS7W}G9Ba54ZzH)-T@@ab8pfg*@3 z4pMeEoU0IdWiw-t3?4472y4FVPb=vTEu-2urCDNOk<5A%O8}TEOj<*hr&o$0#g)9BrjshWr8ynOPCsO2iJF{F9CR+H&|2+0F|4JF=lUrpbNf6!M;jH!idb+=g( z?NDjr3|4ng)M_K3sv&W)1i)3fQ!)DR=SVn;NVkINiIf*KK4}Me_8g4*Q_)YR>fuKm z?Q4s8Ja?D63NnBY^8Dn)rv=Z^lk^eyWeCtJ-mh~z0Vyg4`c^D^+`T8&md5GXi7wX-2CR3$id^E{mH2!G@sAP z)9laRjaWfGN9*EnN^q?01RA=Cn0qBm$!G3b;2|XM05aTPj3AMj)G~HG$P?pm61)0x zHyKDm$5N%D&)AeEv2H0%wfizlw%k_#P{H=w(h(iOmm5|q-#ZHILH>J8M({<3G-_~; zM@_RT%!?-V(IKY|v)S}Fed`H@YPv5Xxc#138Hu~HtjnUY{7WXL_2uj2Kvy^iy~*an z^{cBXhWnke0m}+Zo3ev5Nh?d&hMHhh=v*(r!T)e-lkYw`Q!F;*HB9Q4XlmByK1!4M zTr^tH7U`5AMiB>~;ESwyKIoVrjLniKD4f-GF#Z{M?*2n1RMoaS(yqu&UH>p|^QVUC zc{eL^Gzl~l$fc2>l`bWmZnbaMuq-=U=|#Z6B$Eg+*2-VwXGsC`O+;f zi{ngxqvu9sA^u$HZsD&A2ag6cZFW~>jTLG2MMqwW954!STt+up;<;k((@VFqs+KYO z5bPwUOGua<7GaC@>p;itF=6==Jsn{9cA*Z>)HagWD?KhrRH$bo+e;)mpd%shH5W5M z=m&QM)3jk!(Dl(I-jdZ@i~4^Rs9>(Cs1SkJQUMhvBrr%iTHPB#Ue9y-=md!0V}|J= zn&30HcI-HMj+1@=Hish?qw* za)j(`=c|<#xK#WQkN0EEnpyiRR}Pnr^(}J`X6qYywdMm#={5@eA=(YOAzF3FgYJ^1 z5rabY&^@2&tG9{0CWo$+Tx6+Y9OzE=ZQ5Rqw-nfF7tBt-xhkO4x3}%jGdRQu)ld1t|l1 zY25Qh^}fF^oy?~RySK4fcPoW8jcCv2zbZvX0Y11!r05hOe6w22&(kFRf)yCYWtvSP z47DBJEC@Dv6f~IafkE{>BpCiSgeN=oha}B>Bf3?&G9;XU-l0>e7vkkQ9W#Z!mE(0P z6f{8d>()&C^WCR{kvDg5)VZKCLgF#RveU(CYPWs#NU+xu_q0?D+6Q2?4izVYKPU>ur}>QR9!QOw`b9?hSkBsYq-5DdMpU`%LOjwG zz8!vNUAdZ-Yt<}|0n_wLYR_QY4@EZ?D~Iv5!SN^n-l*^G9o~)=nID=U^J$ZW|4v83yPlhNH;5ZP zi%G9W+xy<<WpL`t#?Rgf|vhAlG}z&*f6DCO1UH;)N;F%ps(D z+Z`mcR&6n;wgBA+;A{J7;4&h^oGuMl&IqqR-wf>%Z_>n;%Z&Qu_v){d(&oN7FOz1( zo93RR@r3AQwHQZ_jg55&>4cv-uUlEpfwk#Mqm!L1?LQ-_r+Ig^9 zxgxE4AG@-gsX##Qg58lB89MlgfVO|h6g5C?O&C5blHSwAGf&dsMC8tbQtf%n<7xYK zoI@j`FPiY+4-b{yqL9=kb%|N!pN@O{&>$Y$G6=dAv1B|`6%L@;MZVqW_|iU-SI=c) zQ#dq2T=#$C!|P&OtqZ-;i-UtOE!{&#VC=2rG+~LLYQv|j^q`Q;2*B~xOipc zX|`@f%9^Nw;jsiSawnLLVN(_JIO49QOp_JRIUU0gPExGOIW4DiLNA&(95uMHs1nu#vFC84LkY zf{aWyTK=`Q$a!29Zuk!CYu?SWkJ+J3R~D<1^@U<|VrVr(Gs&h8ykBP5SNxPJl)m2n zxSyS-)^O}%Y?JIs^Qf5MTzx4F59F1<^g7MwJ%tV{qM+A*SqHM#xV%k(lAjUIP=!|f zgn+a!FFjL0%b|*aIA|suSm$Ek(g;*?vYt`4_8ZdeV%(#ox|!~#s_giOIQFt>{NK{n zWzsgOYD;SMSPyY4Z|0QJPO7hXnE3s|u%K~oWe5v&{k2J!4%2#yj|}U{SdX-715g3| z0C#^Fn!D%z1U9Ra!GrM}8*QXIcVY1!F{E5dCoC3ThuJItX8T!P#QjXvSHef~kcWi>y00gJ0ekYM!%4CZR5Hc)T#eyxiIy@+ zDkATB8p9nZhjzb|8}1-}-#=c4CNt7X`44An9!9-ON|vqmn`P6E9861A9^Mu`b~jAk z0WLozouJKt955Pi848w;PI!wtuY3nod>Jr0F5o6E&O{hl6WaS$En2`Uek{uf-xh=Z ztUKbr5$AP1$?D$!t?@|>r{pT%d!CC62?sXwvMo?NspjBbXXuE!XVw(Z zg{Z>$yzp~?ftUWFO>R~WJi^Y=l=DAF*U>;;~W^uT)bQE{I-kGoRILKHtg5;ra*jPd2 zy5)WwK4MP}jdZC{EPOFhQNeG56b9Hr$jAn(2))5_V1;u}UjW_Z0 zK~zgT?8@mW$tOb}lMTAIN_GdF-BevvuNK+U@9)!!QJOx&ZR7f^KsEC@qM}D=5^v%b z+@hILOEjwBS_QmP9FA;vtV;&YiiQLS<4&9UwVh*%%DS6M#BeG1un4>l>ry>v4Jc9=vYW^69*Fta9|0 z#?o3GBqLm>O0pZ_s4ORoZJl^HII^9cofo?MH-pCoNAwDjho!hvxmkjh456% zl;$ZKJpP0Y#eYw`50OPjLCM+~CRC=ax)O7*_p;;heFjZ86;nr%aQ9?|=k!NUmNt@2 zjOg8P^^^pn;{R421vF0xq~ISt97OP-P0pZq-X+W;{vK?+tk*7-XfT`3To1yaQU#{vQWOlwGX=vI~#1<&m zl%F(fXTs!lbN@~XftD@l-S^JcZe3PvJ)Z@^s^8T%?fnP=~F_7d}caSCfEW2K22ZYU*%817FMO(fa{DOz)F#S zF@Uja#4IgqGXmgrlJ1U@E_)u#fhQ$;? z(i?%t7}LO{;=#jNbGZKJyNISR6l}kq2+eM30oox0fRX@GZ(`L4%i{MIj0D^!5Mp>G*!!f6}R zA%mngA3l%)YvslKTMM-gbSUW(w(Q;_J#x3xYxo!8oh~PhT7~X#PAAo_3k{eHQy?^6 z`DXl@qk8#fu2q z4{zY4m+Pr?LxU~V&6UuxRcn*7%sIFgHD@JVa?$PN#{h1B^v_UBRVh1>5`y6(ml6WC z0-|%1-$AJ7OH*8GZ&Z9y5_K#3C&MDq=SkfHjj0@OlldUR*&vBibUh(*z4HU_!SfW(L5igcDvBHtp&P?HvdaYiX|w1yM!~EgwQT0) z)puOP@GLcw&YdTCJbtJ{zH$Pr*4EXi1pyftJ)4?c@!9w^HNX=PYX<`$aWpBf(8KdD zT77II=0-D0B-VMU}`(0wb%8!;YfKW!_74!^b z!0!Dszth(rz728w4;Ms~7DJ&QoL$ON`l_Z!o&_+KmSVA4k}HBM2Iyo{*<;iyo;0Zs z?e4FHZ2J32^RHw0jaA|I-#o^wq|%Yb2>t1QeuF3AoD145U(}wIVFDR>w5bLV{=Aog z?h(WfY_4P#cfr{be97PuW4d`y9$#)Z=ZDyT760JbZb(nA{iY1=M(?EGnpTZ~sHBBX zm#O{plUjSf8immGc9{LSYkQ4l`~d(9cyOYR21ga*HyR`(<%-{2b~QXzQE|9d;gqiZK-Sl_--lQnM6B`PqYx3^0*((YivML-#--6*=o zlfq%aa5IgR__D>ijL!MVc61|Vw`fb)X5J<;uwtqEkGKwoI&&*O0p){_fd1JAo9i7a z8oe3RM+XaNVm4}uZKtYV{(D-s_k0Gh(JL!zugAqG${M0@&e3e0%;Ko`qOacN;~-*! z*G{|3xs!otH>3HzUKfvgGYO#kZ8*AAF}*Klq{jAO7KhV4s4{@$yJD05e69zCW9If#)~v5B|HxsK0ihv8~(QZHqZ`=r%k^Q{{jM1e@HoLDY1 zNit75L8*XnQK$K4BDCkD2Uslhu4q$Y0Mb^!du*fsMVThDe#_<09`jub9SwMW&M@oW zcCB=YE^{U(g}#u6dsN3;rgUF#2FvW1*uBtio;2?Q4+t-VmUbWdZejwH(F$a-$d0-O zD8fX1p43gp_`?_MuDqcO4l8Wc?`u%2e@3DybqYjUwv z6#t6Fm(nY4a$WDvjEmhw74SfXxtG35SVM2nbv9nDIV(P$g)Oq7f*ZE_k?CoJh=;no z@re?%X~^l!G+oTb>PCI_XZs63WBv}jdhO} zdWFCaFOu$7wMS%W1%tRGdz~-MiEbwPZtSjP8}>+}d^lbC9nDX3>LBfx$-Ou#*ATnOkHp64G_&N#0cnP~J5c>C}#L za!O)nK)G7)+$}P&x$(mRXoa9|_26bs`eWfe2pgC-oBHoCF9{HsH%vQL-We`+?$OolF*il}+5?V7I$4rX`p zJs^M}_Pke3_h{eMV0heS61V3GoUoDa`0p%fD$|yNH9dTUz`fM6$zMu*z?%y4K}ndn zss+)N#E(G4$z%)*d4W4F7x$ypK8QkC#u@8m$jOKsa8}1>_s`%yUH*ZZHo<3We{Pq1 z^zAfWnlgqoHEK2$Eqyvw1}2*Gn)Wu-krPw8mDGPl6uyeL?_?#OdFVx(ACJVyj%_dByMoHKXLipL;uNowcZU`YV5K>YFAQFy9{#or@D?AI#(<^=x_g5LD66=a(%K z6Nn|naxEEjVzUcgCEBkaPLu(?y?s-gdp^3Rx=Ueai)k8j$u-oUF(WM>boC!a&r1&h z==Aty!}VQnl+f>dJE4l*pc8eluHt=}PfVF8+-zs3^7_WGkJ&W!G!r@4`UWwRTpSG}2*pZrd>9-C1)I1;fGUZ=CqcW>?M;v)NLveL6#;xPHu? zfjf_qaK^hJb^OgE50PuBNz*aR>xQr5LWUtUGnjj`>PPcm4+dyy*;pLX*4uJ+r*AZC zBj5ed_wBYRp^-u_R;$poGOp1C2i1vNg%pSMYFIz&8|fRS8COp}_L*LIiU`lW?a@@i z8{Pa~%fQ^`7sWY`Z^-EX`m<=+_UtAsxKSRK!*x)s$)OyLm=xRwWm28sAx{xw{PleZv z4;4Uwk6SRpq82>4Sa5nt=LGR;H9o*-(5Rf;rQ6>vg7_3+t+qEf(<(b;*rF}|XAmL! zW9HA>*%9!+sh(AcC2u`B44l76hg+AJ2AJzzxnX!RA(Gdfh8zASH%ZO03^LK?nfHMa zpsb)ywbz!QWVPq|(1=&0*6r7sTMwOVdofO^IzDT_ljqdZcN0Bdnf--}?gpMwH|)E{ zwq&QBFpp0}U_?9+%|;u?OZK&)pQ;>`aQ`@y1;SDG=#!v$Rce77yeJQqYbex+VnVAn z(PJ!NYPn`I`GPH);B{?1(DMxOTQ z?6zZ5T}5MxI40TF7^oh4Hc@ZcsLJ?JAn8#%UY`r}?hRJo5j<4vLFbYV%ym^^D8t>Ol@S7cLZ7y4=E2+-5N6ts2@^n$ zP`CJi(;UPiGzsg(`{mZV-cvU7lK&ixrvo~>Vk3~ry3BPjyAk3a^-vhJl_&FVk<^mr zN`I5+p!2Fx8QEt}`tBctu6p_5+dlJ-c@6%PsjG9}dFs4^<_I^5r=)f6gWPsO_m8o= zP0j=a-}cL%K{PdRG}5>FVW$Y`1dXJp3*o3dQEx3Ts8Nqr zT_hoqQMDl~VQDU^w03})B4Kp7{xzS>TspO#X07|fvg#yHfceR~{W4b$3wYxBWw-*UD5BMLKAfSNk#(vOyk}sIN0(;^&-t21bVk9 z5elpw%oH7y<8dv=cU+NyzKcJ?`7M{YlO`p9KFej0zdSSccqU0@IM4P^W0;+4`fc-&R-$iEywEXQFxu=e zqD>rg*AO>>LH13q#6{KE-}fc_;UiOM=TmrQ_Yup^@LG(|VfhonzT3&~4QFJ4{HG6? z$ney#nP`B!H{U^y>Xfq9!=UDl0>7s&viQr^JLJ!}Ody*4ySL>8Fm^K$)q)+$aB5w> zlb7Ex;l0IRx_Z@(6@kcjzHs~iptXY#46Gfl!t2|fj{T*zjCoqbyHKu%Y0Z{Ou^bOp zWPsThu4_zKWWbBPMA-?7AQWmAL`;rV2oH>~A7F)UHS*8vR&HuEiu)rGWrI#JTZ-qf znYmY!R}zR{j=V5avHei(H3Umq}|iRHHGxh<<>mv*cT;O@uR%zu@+!_v@G8tH5%@>&tbz zd_hCBfAM`~{Tdm?{$eeEl?zb85<`oK$f0l`t=f3!ElXVmol_!y2umoL)6f9J9ijx6 zz`|m|{O90>B|`;l(^32QDG?=xudZ}4yT6NI!>bqg6cKTGR|#F4NBjp5sg97YU`C*! zD)VEsjQ%yTfS#(f7Dk>~nwtdz0}v>Q1^ZuMSfN*7F@_KZ;f#``jXbcAy)`8aDTjJv zz!efSoL2Ym%-!$bhu{GD8tZ!X^{(6mRcTmK8m2n)c=FMG&wL$9f3OpY?7!0R%2zA( z$L^EzM?Nj}!>60X-?Lf`NAK z-tZh&1I*CwDfvIL|6OAG_{zA80DzISi4*(OcM3@O?F80$IDdc$zWHB+2(CF|pB32~ z0GPMw^1<1ab+0ZdGS1bZdgRDQp@8{6QotbNVZ!m_0f~C?FHWIsw500cYVdfkJmC&p zHw^zvt%LFJa>eV5W`hH)QH1QdN_jHx<0q#-x>)zNj{A%vfKdA=@#8O-eHy>P{#RJI zu)i+#rIRGA1qy$aTAKr>7u-D<+;R~G6jK;Nedib*`-lphg@^XSZ5U2_A& z?Vkr({ny<7Ah;%b?zCi?(V>C7i>$;#y;LWUR!;|x#u?_dkR`S2^9(% zuPK@fEjl*20v?<_K0PYmtHb~G@_!xbq_8^B$D}lw?!;}S)===l#KaX1OE$&eSlu_1 zZLuaD4DQ_}?uA;H5NAL{A%W00cB>?9a47?GC=wGZT6(J1G_wmUUh*5Gg zhw`@m$go{`rJ7qP@Fiwgu3OsB;^T;NvKI;(m;`K_xPJt572q-X9wq(&u=k7kcU0Nf zV!F>ELV zGee_x{{d6M)EB#=wt4Zexen(xn-i(pj@_FOuSV zxX2^nwK$W2UfsbyFGQ>?h!9G+7_{6?a+QqOqf2%i;^HbDJLUk(Mm_c1E3AmLadbMv z?)IyF9IENw-C2e$-AavX { + var author = document.getElementById("authorship-0"); + var not_author = document.getElementById("authorship-1"); + not_author.onchange = handle_not_author; + author.onchange = handle_not_author; + handle_not_author(0); +}); diff --git a/submit/static/js/filewidget.js b/submit/static/js/filewidget.js new file mode 100644 index 0000000..b897267 --- /dev/null +++ b/submit/static/js/filewidget.js @@ -0,0 +1,20 @@ +/** + * Populate the upload selection widget with a filename. + **/ +var update_filename = function() { + if(file.files.length > 0) { + document.getElementById('filename').innerHTML = file.files[0].name; + document.getElementById('file-submit').disabled = false; + } + else { + document.getElementById('file-submit').disabled = true; + } +}; + +/** + * Bind the filename updater to the upload widget. + **/ +window.addEventListener('DOMContentLoaded', function() { + var file = document.getElementById('file'); + file.onchange = update_filename; +}); diff --git a/submit/static/sass/manage_submissions.sass b/submit/static/sass/manage_submissions.sass new file mode 100644 index 0000000..787fbfb --- /dev/null +++ b/submit/static/sass/manage_submissions.sass @@ -0,0 +1,112 @@ +//colors +$color_celeste_approx: #ccc + +@media only screen and (max-width: 320px) + .box + padding: unset + padding-bottom: 0.75em + +@media only screen and(max-width: 760px),(min-device-width: 768px) and(max-device-width: 1024px) + table + display: block + + thead + display: block + tr + position: absolute + top: -9999px + left: -9999px + + + tbody + display: block + + th + display: block + + td + display: block + border: none + position: relative + padding-left: 30% + &:before + position: absolute + top: 6px + left: 6px + width: 45% + padding-right: 10px + white-space: nowrap + + &.user-submission + &:nth-of-type(1):before + content: "Status" + font-weight: bold + + &:nth-of-type(2):before + content: "Identifier" + font-weight: bold + + &:nth-of-type(3):before + content: "Title" + font-weight: bold + + &:nth-of-type(4):before + content: "Created" + font-weight: bold + + &:nth-of-type(5):before + content: "Actions" + font-weight: bold + + + &.user-announced + &:nth-of-type(1):before + content: "Identifier" + font-weight: bold + + &:nth-of-type(2):before + content: "Primary Classification" + font-weight: bold + + &:nth-of-type(3):before + content: "Title" + font-weight: bold + + &:nth-of-type(4):before + content: "Actions" + font-weight: bold + + + + tr + display: block + border: 1px solid $color_celeste_approx + + .table + td + padding-left: 30% + + th + padding-left: 30% + + +.box-new-submission + padding: 2rem important + +.button-create-submission + margin-bottom: 1.5rem + +.button-delete-submission + border: 0px + text-decoration: none + + +/* Welcome message -- "alpha" box */ + +/* This is the subtitle text in the "alpha" box. */ +.subtitle-intro + margin-left: 1.25em + +.alpha-before + position: relative + margin-left: 2em diff --git a/submit/static/sass/submit.sass b/submit/static/sass/submit.sass new file mode 100644 index 0000000..f3f2774 --- /dev/null +++ b/submit/static/sass/submit.sass @@ -0,0 +1,440 @@ +/* Cuts down on unnecessary whitespace at top. Consider move to base. */ +main + padding: 1rem 1.5rem + +.section + /* move this to base as $section-padding */ + padding: 1em 0 + +.button, p .button + font-family: "Open Sans", "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif + font-size: 1rem + &.is-short + height: 1.75em + svg.icon + top: 0 + font-size: .9em +.button.reprocess + margin-top: .5em +/* Allows text in a button to wrap responsively. */ +.button-is-wrappable + height: 100% + white-space: normal +.button-feedback + vertical-align: baseline + +.submit-nav + margin-top: 1.75rem + margin-bottom: 2em !important + justify-content: flex-end + .button + .icon + margin-right: 0 !important +/* controls display of close button for messages */ +.message + button.notification-dismiss + position: relative + z-index: 1 + top: 30px + margin-left: calc(100% - 30px) + +.form-margin + margin: .4em 0 + +.notifications + margin-bottom: 1em + +.policy-scroll + margin: 0 + margin-bottom: -1em + padding: 1em 3em + max-height: 400px + overflow-y: scroll + @media screen and (max-width: 768px) + padding: 1em + @media screen and (max-width: 400px) + max-height: 250px +/* forces display of scrollbar in webkit browsers */ +.policy-scroll::-webkit-scrollbar + -webkit-appearance: none + width: 7px +.policy-scroll::-webkit-scrollbar-thumb + border-radius: 4px + background-color: rgba(0,0,0,.5) + -webkit-box-shadow: 0 0 1px rgba(255,255,255,.5) + +nav.submit-pagination + display: block + /*float: right*/ + width: 100% + +h2 + .title-submit + margin-bottom: 0em + margin-top: 0.25em !important + display: block + float: left + width: 10% + display: none + color: #005e9d + .replacement + margin-top: -2rem + margin-bottom: 2rem + font-style: italic + font-weight: 400 + +h1.title.title-submit + margin-top: .75em + margin-bottom: 0.5em + clear: both + color: #005e9d !important + .preamble + color: #7f8d93 + @media screen and (max-width: 768px) + .title.title-submit + margin-top: 0 + +.texlive-summary article + padding: .8rem + &:not(:last-child) + margin-bottom: .5em + border-bottom-width: 1px + border-bottom-style: dotted + +.alpha-before + &::before + content: '\03b1' + color: hsla(0%, 0%, 80%, .5) + font-size: 10em + position: absolute + top: -0.4em + left: -0.25em + line-height: 1 + font-family: serif + +.beta-before + &::before + content: '\03b2' + color: hsla(0%, 0%, 80%, .5) + font-size: 10em + position: absolute + top: -0.4em + left: -0.25em + line-height: 1 + font-family: serif + +/* overrides for this form only? may move to base */ +.field:not(:last-child) + margin-bottom: 1.25em + +.label:not(:last-child) + margin-bottom: .25em + +.is-horizontal + border-bottom: 1px solid whitesmoke +.is-horizontal:last-of-type + border-bottom: 0px + +.buttons .button:not(:last-child) + margin-right: .75rem + +.content-container, .action-container + border: 1px solid #d3e1e6 + padding: 1em + margin: 0 !important +.content-container + border-bottom: 0px +.action-container + box-shadow: 0 0 9px 0 rgba(210, 210, 210, .5) + .upload-notes + border-bottom: 1px solid #d3e1e6 + padding-bottom: 1em + +/* abs preview styles */ +.content-container #abs + margin: 1em 2em + .title + font-weight: 700 + font-size: x-large + blockquote.abstract + font-size: 1.15rem + margin: 1em 2em 2em 4em + .authors + margin-top: 20px + margin-bottom: 0 + .dateline + text-transform: uppercase + font-style: normal + font-size: .8rem + margin-top: 2px + @media screen and (max-width: 768px) + blockquote.abstract + margin: 1em + +/* category styles on cross-list page */ +.category-list + .action-container + padding: 0 + form + margin: 0 + li + margin: 0 + padding-top: .25em !important + padding-bottom: .25em !important + display: flex + button + padding-top: 0 !important + margin: 0 + height: 1em + span.category-name-label + flex-grow: 1 + +.cross-list-area + float: right + width: 40% + border-left: 1px solid #f2f2f2 + + @media screen and (max-width: 768px) + width: 100% + border-bottom: 1px solid #f2f2f2 + border-left: 0px + +.box-new-submission + text-align: left + .button + margin: 0 0 1em 0 +/* tightens up user data display on verify user page */ +.user-info + margin-bottom: 1rem + .field + margin-bottom: .5rem + .field-label + flex-grow: 2 + span.field-note + font-style: italic + +.control .radio + margin-bottom: .25em + +/* Classes for zebra striping and nested directory display */ +$stripe-color: #E8E8E8 +$directory-color: #CCC + +ol.file-tree + margin: 0 0 1rem 0 + list-style-type: none + li + margin-top: 0 + padding-top: .15em + padding-bottom: .15em + padding-left: .25em + padding-right: .25em + background-color: white + &.even + background-color: $stripe-color + button + vertical-align: baseline + form:nth-of-type(odd) + li + background-color: $stripe-color + .columns + margin: 0 + .column + padding: 0 + .columns + margin: 0 + > li ol + border-left: 2px solid $directory-color + margin: 0 0 0 1rem + li + padding-left: 1em + &:first-child + margin-top: 0 + +/* For highlighting TeX logs */ +.highlight-key + border-top: 1px solid #d3e1e6 + border-bottom: 1px solid #d3e1e6 + padding: 1em 0 2em 0 +.tex-help + background-color: rgba(28, 97, 246, 0.40) + /* background-color: rgba(152, 35, 255, 0.40) */ + color: rgba(8, 32, 49, 1) + padding: 0 0.2em + +.tex-suggestion + background-color: rgba(255, 245, 35, 0.60) + color: rgba(8, 32, 49, 1) + padding: 0 0.2em + +.tex-info + background-color: rgba(0, 104, 173, 0.15) + color: rgba(8, 32, 49, 1) + padding: 0 0.2em + +.tex-success + background-color: rgba(60, 181, 33, 0.15) + color: rgba(9, 44, 1, 1) + padding: 0 0.2em + +.tex-ignore + background-color: rgba(183, 183, 183, 0.30) + color: rgba(9, 44, 1, 1) + padding: 0 0.2em + +.tex-warning + background-color: rgba(214, 118, 0, 0.4) + color: rgba(33, 22, 8, 1) + padding: 0 0.2em + +.tex-danger + background-color: rgba(204, 3, 0, 0.3) + color: rgba(52, 15, 14, 1) + padding: 0 0.2em + +.tex-fatal + background-color: rgba(205, 2, 0, 1) + color: rgba(255,255,255,1) + padding: 0 0.2em + + +/* Classes for condensed, responsive progress bar */ +.progressbar, +.progressbar li a + display: flex + flex-wrap: wrap + justify-content: center + align-items: center + margin: 0 + +.progressbar + /*default styles*/ + li + background-color: #1c8bd6 + list-style: none + padding: 0 + margin: 0 .5px + transition: 0.3s + border: 0 + flex-grow: 1 + li a + font-weight: 600 + text-decoration: none + color: #f5fbff + padding-left: 1em + padding-right: 1em + padding-top: 0px + padding-bottom: 0px + transition: color 0.3s + height: 25px + li a:hover, + li a:focus, + li a:active + text-decoration: none + border-bottom: 0px + color: #032121 + + /*complete styles*/ + li.is-complete + background-color: #e9f0f5 + li.is-complete a + color: #535E62 + text-decoration: underline + font-weight: 300 + transition: 0.3s + &:hover + height: 30px + + /*active styles*/ + li.is-active, + li.is-complete.is-active + background-color: #005e9d + box-shadow: 0 0 10px 0 rgba(0,0,0,.25) + li.is-active a, + li.is-complete.is-active a + color: #f5fbff + font-weight: 600 + cursor: default + pointer-events: none + height: 30px + li.is-active a + text-decoration: none + + /*controls number of links per row when screen is too narrow for just one row*/ + @media screen and (max-width: 1100px) + li + width: calc(100% * (1/6) - 10px - 1px) + +/* info and help bubbles */ +.help-bubble + position: relative + display: inline-block + &:hover + .bubble-text + visibility: visible + &:focus + .bubble-text + visibility: visible + .bubble-text + position: absolute + visibility: hidden + width: 160px + background-color: #F5FAFE + border: 1px solid #0068AC + color: #0068AC + text-align: center + padding: 5px + border-radius: 4px + bottom: 125% + left: 50% + margin-left: -80px + font-size: .8rem + &:after + content: " " + position: absolute + top: 100% + left: 50% + margin-left: -5px + border-width: 5px + border-style: solid + border-color: #0068AC transparent transparent transparent + + +/* Formats the autotex log ouput. + + Ideally this would also have white-space: nowrap; but it doesnt play nice + with flex. Unfortunately, the widely accepted solution + (https://css-tricks.com/flexbox-truncated-text/) is not working here. */ +.log-output + max-height: 50vh + height: 50vh + color: black + overflow-y: scroll + overflow-x: scroll + max-height: 100% + max-width: 100% + background-color: whitesmoke + font-family: 'Courier New', Courier, monospace + font-size: 9pt + padding: 4px + +/* See https://github.com/jgthms/bulma/issues/1417 */ +.level-is-shrinkable + flex-shrink: 1 + +/* Prevent the links under the mini search bar from wrapping. */ +.mini-search .help + width: 200% + +/* This forces the content to stay in bounds. This also fixes the overflow + of selects (without breaking the mini-search bar). */ +.container + width: 100% +columns + max-width: 100% +column + max-width: 100% +.field + max-width: 100% +.control-cross-list + max-width: 80% diff --git a/submit/templates/submit/add_metadata.html b/submit/templates/submit/add_metadata.html new file mode 100644 index 0000000..da97048 --- /dev/null +++ b/submit/templates/submit/add_metadata.html @@ -0,0 +1,119 @@ +{% extends "submit/base.html" %} + +{% block title -%}Add or Edit Metadata{%- endblock title %} + +{% block within_content %} +
    +
    + {{ form.csrf_token }} + {% with field = form.title %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.authors_display %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="textarea is-danger")|safe }} + {% else %} + {{ field(class="textarea")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.abstract %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="textarea is-danger")|safe }} + {% else %} + {{ field(class="textarea")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.comments %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/add_optional_metadata.html b/submit/templates/submit/add_optional_metadata.html new file mode 100644 index 0000000..e12b7eb --- /dev/null +++ b/submit/templates/submit/add_optional_metadata.html @@ -0,0 +1,165 @@ +{% extends "submit/base.html" %} + + +{% block title -%}Add or Edit Optional Metadata{%- endblock title %} + +{% block within_content %} +
    +
    + {{ form.csrf_token }} +
    +
    + {% with field = form.doi %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.journal_ref %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.report_num %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.acm_class %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.msc_class %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} +
    +
    +
    +
    +

    + If this article has not yet been published elsewhere, this information can be added at any later time without submitting a new version. +

    +
    +
    +
    +
    +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/admin_macros.html b/submit/templates/submit/admin_macros.html new file mode 100644 index 0000000..10e53bc --- /dev/null +++ b/submit/templates/submit/admin_macros.html @@ -0,0 +1,76 @@ +{% macro admin_upload(submission_id) %} +
    +
    +
    +

    Admin

    + + +
    +
    + + + + {% user.fullname %} 2019-04-20 +
    +
    +
    +
    +

    Checkpoints

    +
    +
    + + 2019-04-11T09:57:00Z + + + Title might be longer than this + +
    + +
    +
    +
    + + 2019-04-11T09:57:00Z + + + Title might be longer than this + +
    + +
    +
    +
    +
    +{% endmacro %} diff --git a/submit/templates/submit/authorship.html b/submit/templates/submit/authorship.html new file mode 100644 index 0000000..9c043e6 --- /dev/null +++ b/submit/templates/submit/authorship.html @@ -0,0 +1,66 @@ +{% extends "submit/base.html" %} + +{% block addl_head %} +{{super()}} + +{% endblock addl_head %} + +{% block title -%}Confirm Authorship{%- endblock title %} + +{% block within_content %} +
    +{{ form.csrf_token }} +
    +
    +
    + Confirm the authorship of this submission + {% if form.authorship.errors %}
    {% endif %} + {% for field in form.authorship %} +
    +
    +
    + {{ field|safe }} + {{ field.label }} +
    +
    +
    + {% endfor %} + +
    +
    + + {% if form.proxy.errors %} + {% for error in form.proxy.errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} +
    +
    + {% if form.authorship.errors %} + {% for error in form.authorship.errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} + {% if form.authorship.errors %}
    {% endif %} +
    + +
    +
    +
    +

    Authorship guidelines

    +

    Complete and accurate authorship is required and will be displayed in the public metadata.

    +

    Third party submissions may have additional requirements, learn more on the detailed help page.

    +
    +
    +
    +
    +
    +{{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/base.html b/submit/templates/submit/base.html new file mode 100644 index 0000000..17593e2 --- /dev/null +++ b/submit/templates/submit/base.html @@ -0,0 +1,61 @@ +{%- extends "base/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} +{% import "base/macros.html" as macros %} + +{% block addl_head %} + +{% endblock addl_head %} + +{% block alerts %} +{# don't show alerts in the place the base templates show them #} +{% endblock alerts %} + +{% block content %} + {% if submission_id and workflow %} + {{ submit_macros.progress_bar(submission_id, workflow, this_stage) }} + {% endif %} + +

    + {% block title_preamble %} + {% if submission and submission.version > 1 %}Replace:{% else %}Submit:{% endif %} + {% endblock %} + {% block title %}{% endblock title %} +

    + + {# alerts from base macro #} + {{ macros.alerts(get_alerts()) }} + {# Sometimes we need to show an alert immediately, without a redirect. #} + {% block immediate_alerts %} + {% if immediate_alerts -%} + + {%- endif %} + {% block more_notifications %}{% endblock more_notifications %} + {% endblock %} + + + {% if submission and submission.version > 1 %} + {# TODO: change this when we have better semantics on Submission domain class (e.g. last announced version) #} +

    Replacing arXiv:{{ submission.arxiv_id }}v{{ submission.version - 1 }} {{ submission.metadata.title }}

    + {% endif %} + {% if form and form.errors %} + {% if form.errors.events %} +
    + {% for error in form.errors.events -%} +
  • {{ error }}
  • + {%- endfor %} +
    + {% endif %} + {% endif %} + {% block within_content %} + Specific content here + {% endblock within_content %} +{% endblock content %} diff --git a/submit/templates/submit/classification.html b/submit/templates/submit/classification.html new file mode 100644 index 0000000..bc669a6 --- /dev/null +++ b/submit/templates/submit/classification.html @@ -0,0 +1,94 @@ +{% extends "submit/base.html" %} + +{% block title -%}Choose a Primary Category{%- endblock title %} + +{% block more_notifications -%} + {% if submitter.endorsements|endorsetype == 'None' %} +
    +
    +

    Endorsements

    +
    +
    +
    + +
    +

    Your account does not currently have any endorsed categories. You will need to seek endorsement before submitting.

    +
    +
    + {% endif %} + {% if submitter.endorsements|endorsetype == 'Some' %} + + {% endif %} +{%- endblock more_notifications %} + +{% block within_content %} + +

    Select the primary category that is most specific for your article. If there is interest in more than one category for your article, you may choose cross-lists as secondary categories on the next step.

    +

    Category selection is subject to moderation which may cause a delay in announcement in some cases. Click here to view a complete list of categories and descriptions.

    +
    +
    + {{ form.csrf_token }} +
    +
    + {% with field = form.category %} +
    +
    + +
    + {% if field.errors %} + {{ field(class="is-danger")|safe }} + {% else %} + {{ field()|safe }} + {% endif %} +
    + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + {% endwith %} +
    + +
    +
    +
    +

    + + Select a category from the dropdown to view its description +

    +
    +
    +
    + +
    +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_cancel_request.html b/submit/templates/submit/confirm_cancel_request.html new file mode 100644 index 0000000..2838ab1 --- /dev/null +++ b/submit/templates/submit/confirm_cancel_request.html @@ -0,0 +1,36 @@ +{% extends "submit/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block title_preamble %}{% endblock %} +{% block title -%}Cancel {{ user_request.NAME }} Request for E-print {{ submission.arxiv_id }}{%- endblock title %} + + +{% block within_content %} +

    {{ submission.metadata.title }}

    + + +
    + {{ form.csrf_token }} +
    +
    +
    +
    +

    Delete {{ user_request.NAME }} Request

    +
    +
    +

    + Are you sure that you want to cancel this {{ user_request.NAME }} request? +

    + {{ form.csrf_token }} + +
    + + No, keep working +
    +
    +
    +
    +
    +
    +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete.html b/submit/templates/submit/confirm_delete.html new file mode 100644 index 0000000..5f1fac5 --- /dev/null +++ b/submit/templates/submit/confirm_delete.html @@ -0,0 +1,30 @@ +{% extends "submit/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block title -%}Delete Files{%- endblock title %} + +{% block within_content %} +
    + {{ form.csrf_token }} +
    +
    +
    +

    + This action will remove the following files from arXiv: +

    +
      +
    • {{ form.file_path.data }}
    • +
    + {{ form.file_path }} + {{ form.csrf_token }} + +
    + Keep these files + +
    +
    +
    +
    +
    +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete_all.html b/submit/templates/submit/confirm_delete_all.html new file mode 100644 index 0000000..e21c20b --- /dev/null +++ b/submit/templates/submit/confirm_delete_all.html @@ -0,0 +1,27 @@ +{% extends "submit/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block title -%}Delete All Files{%- endblock title %} + +{% block within_content %} +
    + {{ form.csrf_token }} +
    +
    +
    +

    + This action will remove all of the files that you have uploaded. This + cannot be reversed! +

    + {{ form.csrf_token }} + +
    + Keep all files + +
    +
    +
    +
    +
    +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete_submission.html b/submit/templates/submit/confirm_delete_submission.html new file mode 100644 index 0000000..12dcaa7 --- /dev/null +++ b/submit/templates/submit/confirm_delete_submission.html @@ -0,0 +1,40 @@ +{% extends "submit/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block title_preamble %}{% endblock %} +{% block title -%}{% if submission.version == 1 %}Delete Submission{% else %}Delete Replacement for Submission{% endif %} {{ submission.submission_id }}{%- endblock title %} + +{% block within_content %} +
    + {{ form.csrf_token }} +
    +
    +
    +
    +

    {% if submission.version == 1 %}Delete This Submission{% else %}Delete This Replacement{% endif %}

    +
    +
    +

    + {% if submission.version == 1 %} + Deleting will permanently remove all information entered and + uploaded for this submission from your account. Are you sure you + want to delete? + {% else %} + Deleting will revert your article to the most recently announced + version, and discard any new information entered or + uploaded during this replacement. Are you sure you want to delete? + {% endif %} +

    + {{ form.csrf_token }} + +
    + + No, keep working +
    +
    +
    +
    +
    +
    +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_submit.html b/submit/templates/submit/confirm_submit.html new file mode 100644 index 0000000..a801872 --- /dev/null +++ b/submit/templates/submit/confirm_submit.html @@ -0,0 +1,26 @@ +{% extends "submit/base.html" %} + +{% block title -%}Submission Received{%- endblock title %} + +{% block within_content %} +
    +
    +
    +
    +

    + + Your submission has been successfully received.

    +

    Your submission time stamp is: {{ submission.submitted.strftime('%F, %T') }} (UTC).

    +

    All submissions are subject to moderation which may cause a delay in announcement in some cases.

    +

    If you need to make further changes click on "Manage my submission" below. To make changes you must first unsubmit your paper. Unsubmitting will remove your paper from the queue. Resubmitting will assign a new timestamp.

    +
    +
    +
    +
    + + + +{% endblock within_content %} diff --git a/submit/templates/submit/confirm_unsubmit.html b/submit/templates/submit/confirm_unsubmit.html new file mode 100644 index 0000000..2110bd9 --- /dev/null +++ b/submit/templates/submit/confirm_unsubmit.html @@ -0,0 +1,59 @@ +{% extends "submit/base.html" %} + +{% block title -%}Unsubmit This Submission{%- endblock title %} + +{% block within_content %} +
    + {{ form.csrf_token }} +
    +
    +
    +
    + {% block title_preamble %}{% endblock %} +
    +
    +

    + {#- TODO: include next announcement time as part of message. -#} + Unsubmitting will remove your article from the announcement + queue and prevent your article from being published. Are you sure + you want to unsubmit? +

    + {{ form.csrf_token }} + +
    + + Cancel +
    +
    +
    +
    +
    +
    + +{% if submission.version > 1 %} + {% set arxiv_id = submission.arxiv_id %} +{% else %} + {% set arxiv_id = "0000.00000" %} +{% endif %} + +
    +
    + {{ macros.abs( + arxiv_id, + submission.metadata.title, + submission.metadata.authors_display, + submission.metadata.abstract, + submission.created, + submission.primary_classification.category, + comments = submission.metadata.comments, + msc_class = submission.metadata.msc_class, + acm_class = submission.metadata.acm_class, + journal_ref = submission.metadata.journal_ref, + doi = submission.metadata.doi, + report_num = submission.metadata.report_num, + version = submission.version, + submission_history = submission_history, + secondary_categories = submission.secondary_categories) }} +
    +
    +{% endblock within_content %} diff --git a/submit/templates/submit/cross_list.html b/submit/templates/submit/cross_list.html new file mode 100644 index 0000000..b8ad8a9 --- /dev/null +++ b/submit/templates/submit/cross_list.html @@ -0,0 +1,89 @@ +{% extends "submit/base.html" %} + +{% block title -%}Choose Cross-List Categories (Optional){%- endblock title %} + +{% block within_content %} +

    You may cross-list your article to another relevant category here. + If accepted your article will appear in the regular listing for that category (in the cross-list section). + Cross-lists should be of direct interest to the professionals studying that field, not simply use techniques of, or derived from, that field. +

    +
    +
    +

    + Adding more than three cross-list classifications will result in a delay in the acceptance of your submission. + Readers consider excessive or inappropriate cross-listing to be bad etiquette. + It is rarely appropriate to add more than one or two cross-lists. + Note you are unlikely to know that a cross-list is appropriate unless you are yourself a reader of the archive which you are considering. +

    +
    +
    + +{% if formset.items %} +
    +

    (Optional) Add cross-list categories from the list below.

    + {% for category, subform in formset.items() %} + +
    + {{ subform.csrf_token }} +
      +
    1. + {{ subform.operation }} + {{ subform.category()|safe }} + {{ subform.category.data }} + {{ category|get_category_name }} + +
    2. +
    +
    + {% endfor %} +
    +{% endif %} + +
    +
    + {{ form.csrf_token }} + {{ form.operation }} + + {% with field = form.category %} + +
    +
    +
    + {% if field.errors %} + {{ field(class="is-danger")|safe }} + {% else %} + {{ field()|safe }} + {% endif %} +
    + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + +
    +
    + {% endwith %} + +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/error_messages.html b/submit/templates/submit/error_messages.html new file mode 100644 index 0000000..fc7567a --- /dev/null +++ b/submit/templates/submit/error_messages.html @@ -0,0 +1,104 @@ +{% extends "submit/base.html" %} + +{% block title -%}Errors and Help{%- endblock title %} + +{% block within_content %} +
    + +
    +

    File Upload Errors

    +
    +
    file-has-unsupported-characters
    +
    File name contains unsupported characters. Renamed file_name to new_file_name. Check and modify any file paths that reference this file.
    +
    File names for arXiv may only contain a-z A-Z 0-9 . , - _ =. This file has been automatically renamed to a supported character set, but any references to this file in other documents are not automatically corrected.
    + +
    unable-to-rename
    +
    Unable to automatically rename file_name. Change file name and modify any file paths that reference this name.
    +
    File names for arXiv may only contain a-z A-Z 0-9 . , - _ =. This file name, and any references to this file within other documents in the upload, must be renamed with the allowed character set.
    + +
    file-starts-with-hyphen
    +
    File name starts with a hyphen. Renamed file_name to new_file_name. Check and modify any file paths that reference this file.
    +
    Hyphens are not allowed at the beginning of file names. This file has been automatically renamed to remove the hyphen, but any references to this file in other documents are not automatically corrected.
    + +
    hidden-file
    +
    Hidden file file_name has been detected and removed.
    +
    Files beginning with a . are not allowed and are automatically removed.
    + + +
    Hyperlink-compatible package package_name detected and removed. A local hypertex-compatible package name? will be used.
    +
    Styles that conflict with TeXLive 2016's internal hypertex package, such as espcrc2 and lamuphys, are removed. Instead, a local version of these style packages that are compatible with hypertext will be used.
    + +
    file-not-allowed
    +
    Conflicting file file_name detected and removed.
    +
    Files named `uufiles` or `core` or `splread.1st` will conflict with TeXLive 2016 and are automatically removed. These files re.search(r'^xxx\.(rsrc$|finfo$|cshrc$|nfs)', file_name) or re.search(r'\.[346]00gf$', file_name) or (re.search(r'\.desc$', file_name) and file_size < 10) are also automatically removed.
    + +
    bibtex
    +
    BibTeX file file_name removed. Please upload .bbl file instead.
    +
    We do not run BibTeX or store .bib files.
    + +
    file-already-included
    +
    File file_name is already included in TeXLive, removed from submission.
    +
    These files are included in TeXLive, and are removed from individual submissions. re.search(r'^(10pt\.rtx|11pt\.rtx|12pt\.rtx|aps\.rtx|revsymb\.sty|revtex4\.cls|rmp\.rtx)$', file_name)
    + +
    time-dependent-file
    +
    Time dependent package package_name detected and removed. Replaced with filename_internal.
    +
    This diagrams package stops working after a specified date. File is removed and replaced with an internal diagrams package that works for all time. re.search(r'^diagrams\.(sty|tex)$', file_name)
    + +
    aa-example-file
    +
    Example file aa.dem detected and removed.
    +
    Example files for the Astronomy and Astrophysics macro package aa.cls are not needed for any specific build, removed as unnecessary.
    + +
    name-conflict
    +
    Name conflict in file_name detected, file removed.
    +
    Naming conflicts are caused by files that are TeX-produced output, and can cause potential build corruption and/or conflicts.
    + +
    second-unsupported-character
    +
    File file_name contains unsupported character \"$&\". Renamed to new_file_name. Check and modify any path references to this file to avoid compilation errors.
    +
    Attempted fix for this already, would only see this error if renaming failed for some reason or there were multiple character errors in one file name.
    + +
    failed-doc
    +
    file_name.endswith('.doc') and type == 'failed': + # Doc warning + # TODO: Get doc warning from message class + msg = ''
    +
    no failed docs
    +
    + +

    File Upload Messages

    +
    +
    deleted-files
    +
    Deleting a file removes it permanently from a submission. It is important to delete any files that are not needed or used during TeX compilation or included in /anc for supplemental information.
    + +
    ancillary-files
    +
    Files that provide supporting content but are not part of a submission's primary document files are moved into an /anc directory. Checking the "Ancillary" box during upload will create an /anc directory and move uploaded files into that directory. To specify ancillary files in a single .tar upload, create the /anc directory and place ancillary files there. This prevents the TeX compiler from including files in the build that may slow or stop the compilation process and cause unwanted errors.
    +
    +
    +
    + +{% endblock within_content %} diff --git a/submit/templates/submit/file_process.html b/submit/templates/submit/file_process.html new file mode 100644 index 0000000..76655c7 --- /dev/null +++ b/submit/templates/submit/file_process.html @@ -0,0 +1,281 @@ +{% extends "submit/base.html" %} + +{% block title -%}Process Files{%- endblock title %} + +{% block addl_head %} + {{ super() }} + {% if status and status == "in_progress" %} + + {% endif %} +{% endblock addl_head %} + + + +{% block more_notifications -%} +{% if status and status != "not_started" %} + {% if status == "in_progress" %} +
    +
    +

    + + Processing Underway +

    +
    +
    +

    We are currently processing your submission. This may take up to a minute or two. This page will refresh automatically every 5 seconds. You can also refresh this page manually to check the current status.

    +
    +
    + + {% elif status == "failed" and reason %} +
    +
    +

    + + {% if "docker" in reason %} + Processing Failed: There was an unrecoverable internal error in the compilation system. + {% elif 'produced from TeX source' in reason %} + Processing Failed: {{ reason }} + {% else %} + Processing Failed: + {% endif %} +

    +
    + {% if 'produced from TeX source' in reason %} +
    +

    What to do next:

    + +

    Go back to Upload Files

    +

    Help: Common TeX Errors

    +
    + + {% elif "docker" in reason %} +
    +

    What to do next:

    +
      +
    • There is no end-user solution to this system problem.
    • +
    • Please contact arXiv administrators. and provide the message above.
    • +
    +

    Go back to Upload Files

    +

    Help: Common TeX Errors

    +
    + {% else %} +
    +

    What to do next:

    +
      +
    • review the log warnings and errors
    • +
    • correct any missing file problems
    • +
    • edit your files if needed
    • +
    • re-upload any corrected files
    • +
    • process your upload after corrections
    • +
    +

    Go back to Upload Files

    +

    Help: Common TeX Errors

    +
    + {% endif %} +
    + + {% elif status == "failed" %} +
    +
    +

    + + Processing Failed +

    +
    +
    +

    What to do next:

    +
      +
    • review the log warnings and errors
    • +
    • correct any missing file problems. +
    • make sure links to included files match the names of the files exactly (it is case sensitive)
    • +
    • edit your files if needed
    • +
    • verify your references, citations and captions.
    • +
    • re-upload any corrected files
    • +
    • process your upload after corrections
    • +
    +

    Go back to Upload Files

    +

    Help: Common TeX Errors

    +
    +
    + + {% elif status == "succeeded" and warnings %} +
    +
    +

    + + Completed With Warnings +

    +
    +
    +

    Warnings may cause your submission to display incorrectly or be delayed in announcement.

    +
      +
    • Be sure to check the compiled preview carefully for any undesired artifacts or errors - especially references, figures, and captions.
    • +
    • Check the compiler log for warnings and errors.
    • +
    • Double check captions, references and citations.
    • +
    • For oversize submissions fully complete your submission first, then request an exception.
    • +
    +

    Help: TeX Rendering Problems

    +
    +
    + + {% elif status == "succeeded" %} +
    + +
    +

    + + Processing Successful +

    +
    +
    +

    Be sure to check the compiled preview carefully for any undesired artifacts or errors.

    +

    If you need to make corrections, return to the previous step and adjust your files, then reprocess your submission.

    +

    + Avoid common causes of delay: + Double check captions, references and citations. + For oversize submissions fully complete your submission first, then request an exception. +

    + +
    +
    + {% endif %} +{% endif %} +{%- endblock more_notifications %} + + + +{% block within_content %} +
    + {{ form.csrf_token }} + +
    +
    + + {% if not status or status == "not_started" %} +

    This step runs all files through a series of automatic checks and compiles TeX and LaTeX source packages into a PDF. Learn more about our compiler and included packages.

    +
    +
    + + +
    +
    + {% endif %} + + {% if status and status != "not_started" %} + {% if status == "in_progress" %} +

    Files are processing...

    + {% elif status == "failed" %} +

    File processing has failed. Click below to go back to the previous page:

    +

    Go back to Upload Files

    +

    + {% elif status == "succeeded" and warnings %} +

    All files have been processed with some warnings.

    + {% elif status == "succeeded" %} +

    All files have been processed successfully. Preview your article PDF before continuing.

    +

    + View PDF + +

    + {% endif %} + + {% endif %} + + {% if status and log_output %} +

    + Check the compiler log summary for any warnings and errors. + View the raw log for further details. +

    +
    +

    Summary Highlight Key

    + Severe warnings/errors
    + Important warnings
    + General warnings/errors from packages
    + Unimportant warnings/errors
    + Positive event
    + Informational markup
    + References to help documentation
    + Recommended solution
    +
    +
    + + View raw log + + + {% endif %} + + {% if status and status != "not_started" %} +
    + + {% endif%} +
    + +
    + {% if status and log_output %} +
    +
    +
    +

    TeXLive Compiler Summary

    +
    + +
    +
    + {{ log_output | compilation_log_display(submission.submission_id, status) | replace("\n", "
    ") | safe }} +
    + +
    + {% endif %} +
    +
    + +{{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/file_upload.html b/submit/templates/submit/file_upload.html new file mode 100644 index 0000000..cb72246 --- /dev/null +++ b/submit/templates/submit/file_upload.html @@ -0,0 +1,178 @@ +{% extends "submit/base.html" %} + +{% block addl_head %} +{{super()}} + +{% endblock addl_head %} + +{% macro display_tree(key, item, even) %} + {% if key %} +
  • + {% endif %} + {% if item.name %} +
    +
    + {{ item.name }} +
    +
    +
    +
    {{ item.size|filesizeformat }}
    +
    {{ item.modified|timesince }}
    + +
    +
    +
    + {% else %} + {% if key %} + + {{ key }}/{% endif %} +
      + {% for k, subitem in item.items() %} + {{ display_tree(k, subitem, loop.cycle('', 'even')) }} + {% endfor %} +
    + {% endif %} + {% if item.errors %} + {% for error in item.errors %} +

    + {{ error.message }} +

    + {% endfor %} + {% endif %} + {% if key %} +
  • + {% endif %} +{% endmacro %} + +{% block title -%}Upload Files{%- endblock title %} + + +{% if immediate_notifications %} + {% for notification in immediate_notifications %} + + {% endfor %} +{% endif %} + + +{% block within_content %} +

    TeX and (La)TeX submissions are highly encouraged. This format is the most likely to retain readability and high-quality output in the future. TeX source uploaded to arXiv will be made publicly available.

    +
    +
    + +
    + {{ form.csrf_token }} +
    +
    +
    + +
    +
    + +
    + +
    +
    + {% if form.file.errors %} + {% for error in form.file.errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} +

    + You can upload all your files at once as a single .zip or .tar.gz file, or upload individual files as needed. +

    + Avoid common causes of delay: Make sure included files match the filenames exactly (it is case sensitive), + and verify your references, citations and captions. +

    + {% if status and status.errors %} + {% for error in status.errors %} +

    + {{ error.message }} +

    + {% endfor %} + {% endif %} + + {% if status and status.files %} +

    Files currently attached to this submission ({{ status.size|filesizeformat }})

    + + {{ display_tree(None, status.files|group_files) }} + +

    + + Remove All + + +

    + {% elif error %} +

    + Something isn't working right now. Please try again. +

    + {% else %} +

    + No files have been uploaded yet. +

    + {% endif %} +
    + +
    +
    +
    +

    + + Accepted formats, in order of preference +

    + +

    + + Accepted formats for figures +

    +
      +
    • (La)TeX: Postscript
    • +
    • PDFLaTeX: JPG, GIF, PNG, or PDF
    • +
    +

    + + Accepted file properties +

    + +
    +
    +
    + +
    +{{ submit_macros.submit_nav(submission_id) }} +
    + +{% endblock within_content %} diff --git a/submit/templates/submit/final_preview.html b/submit/templates/submit/final_preview.html new file mode 100644 index 0000000..344eba6 --- /dev/null +++ b/submit/templates/submit/final_preview.html @@ -0,0 +1,92 @@ +{% extends "submit/base.html" %} + +{% block title -%}Review and Approve Your Submission{%- endblock title %} + +{% block within_content %} +
    +
    +
    +
    +

    Review your submission carefully! + Editing your submission after clicking "confirm and submit" will remove your paper from the queue. + A new timestamp will be assigned after resubmitting.

    +

    Note that a citation link with your final arxiv identifier is not available until after the submission is accepted and published.

    +

    Once your submission has been announced, amendments to files or core metadata may only be made through + replacement or withdrawal. + Exception: You will be able to update journal reference, DOI, MSC or ACM classification, or report number information at any time without a new revision.

    +
    +
    +
    +
    + +{% if submission.version > 1 %} + {% set arxiv_id = submission.arxiv_id %} +{% else %} + {% set arxiv_id = "0000.00000" %} +{% endif %} + +
    +{{ macros.abs( + arxiv_id, + submission.metadata.title, + submission.metadata.authors_display|tex2utf, + submission.metadata.abstract, + submission.created, + submission.primary_classification.category, + comments = submission.metadata.comments, + msc_class = submission.metadata.msc_class, + acm_class = submission.metadata.acm_class, + journal_ref = submission.metadata.journal_ref, + doi = submission.metadata.doi, + report_num = submission.metadata.report_num, + version = submission.version, + submission_history = submission_history, + secondary_categories = submission.secondary_categories) }} +
    + + +
    + + + +
    + {{ form.csrf_token }} + {% if form.proceed.errors %}
    {% endif %} +
    +
    +
    + {{ form.proceed }} + {{ form.proceed.label }} +
    + {% for error in form.proceed.errors %} +

    {{ error }}

    + {% endfor %} +
    +
    + {% if form.proceed.errors %}
    {% endif %} +
    + + +
    +{% endblock within_content %} diff --git a/submit/templates/submit/jref.html b/submit/templates/submit/jref.html new file mode 100644 index 0000000..24245ae --- /dev/null +++ b/submit/templates/submit/jref.html @@ -0,0 +1,161 @@ +{%- extends "base/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block addl_head %} + + +{% endblock addl_head %} + +{% block content %} +

    Update Journal Reference, DOI, or Report Number

    + {% if require_confirmation and not confirmed %} +
    +

    This preview shows the abstract page as it will be updated. + Please check carefully to ensure that it is correct.
    + You can continue to make changes until you are + satisfied with the result.

    +
    + +
    + {{ macros.abs( + submission.arxiv_id, + submission.metadata.title, + submission.metadata.authors_display, + submission.metadata.abstract, + submission.created, + submission.primary_classification.category, + comments = submission.metadata.comments, + msc_class = submission.metadata.msc_class, + acm_class = submission.metadata.acm_class, + journal_ref = form.journal_ref.data, + doi = form.doi.data, + report_num = form.report_num.data, + version = submission.version, + submission_history = [], + secondary_categories = submission.secondary_categories) }} +
    + {% else %} +

    arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    + {% endif %} + + +
    +
    +
    + {{ form.csrf_token }} + {% with field = form.journal_ref %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.doi %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} + + {% with field = form.report_num %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="input is-danger")|safe }} + {% else %} + {{ field(class="input")|safe }} + {% endif %} +
    +
    + {% endwith %} +
    +
    + +
    +
    + + + +
    + +{% endblock content %} diff --git a/submit/templates/submit/license.html b/submit/templates/submit/license.html new file mode 100644 index 0000000..c6d2772 --- /dev/null +++ b/submit/templates/submit/license.html @@ -0,0 +1,72 @@ +{% extends "submit/base.html" %} + +{% block title -%}Select a License{%- endblock title %} + +{% block within_content %} + +
    + {{ form.csrf_token }} +
    +

    Submission license details - Scroll to read before selecting a license

    +
    +
    +
    +

    This license is irrevocable and permanent. If you plan to publish this article in a journal, check the journal's policies before uploading to arXiv.

    +
    +
    +

    The Submitter is an original author of the Work, or a proxy pre-approved by arXiv administrators acting on behalf of an original author. By submitting the work to arXiv, the Submitter is:

    +
      +
    1. Representing and warranting that the Submitter holds the right to make the submission, without conflicting with or violating rights of other persons.
    2. +
    3. Granting a license permitting the work to be included in arXiv.
    4. +
    5. Agreeing not to sue or seek to recover damages from arXiv, Cornell University, or affiliated individuals based on arXiv's actions in connection with your submission including refusal to accept, categorization, removal, or exercise of any other rights granted under the Submittal Agreement.
    6. +
    7. Agreeing to not enter into other agreements or make other commitments that would conflict with the rights granted to arXiv.
    8. +
    +

    arXiv is a repository for scholarly material, and perpetual access is necessary to maintain the scholarly record. Therefore, arXiv strives to maintain a permanent collection of submitted works, including actions taken with respect to each work. Nevertheless, arXiv reserves the right, in its sole discretion, to take any action including to removal of a work or blocking access to it, in the event that arXiv determines that such action is required in order to comply with applicable arXiv policies and laws. Submitters should take care to submit a work to arXiv only if they are certain that they will not later wish to publish it in a journal or other outlet that prohibits prior distribution on an e-print server. arXiv will not remove an announced article in order to comply with such a journal policy – the license granted on submission is irrevocable.

    +

    arXiv is a service of Cornell University, and all rights and obligations of arXiv are held and exercised by Cornell University.

    +

    If you have any additional questions about arXiv’s copyright and licensing policies, please contact the arXiv administrators directly.

    +
    +
    +
    +
    +
    +

    Select Licence

    + {% if form.license.errors %}
    {% endif %} +
    + Select a license for your submission + {% for subfield in form.license %} +
    + +
    + {% endfor %} +
    + {% if form.license.errors %} + {% for error in form.license.errors %} +

    {{ error }}

    + {% endfor %} + {% endif %} + {% if form.license.errors %}
    {% endif %} +
    +
    +
    +
    +

    Not sure which license to select?

    +

    See the Discussion of licenses for more information.

    +

    If you wish to use a different CC license, then select arXiv's non-exclusive license to distribute in the arXiv and indicate the desired Creative Commons license in the article's full text.

    +
    +
    +
    +
    +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/manage_submissions.html b/submit/templates/submit/manage_submissions.html new file mode 100644 index 0000000..fef208e --- /dev/null +++ b/submit/templates/submit/manage_submissions.html @@ -0,0 +1,175 @@ +{% extends "submit/base.html" %} + +{% block extra_head %}{% endblock %} + +{% block title_preamble %}{% endblock %} +{% block title %}Manage Submissions{% endblock %} + +{% block within_content %} +
    +
    +
    + +

    Welcome [First Name]

    +

    Click to start a new submission

    +
    + {{ form.csrf_token }} + +
    +

    Review arXiv's extensive submission documentation

    + Submission Help Pages
    +

    Need to make changes to your account before submitting? Manage account info here

    + Manage Account +
    +
    +
    + +
    +
    + +
    +
    +
    +

    Submissions in Progress

    +
    +
    +

    Only submissions started with this interface are listed. + View classic list

    +
    +
    +
    + {% if user_submissions|selectattr('is_active')|list|length > 0 %} + + + + + + + + + + + {% for submission in user_submissions %} + {% if not submission.is_announced %} + + + + + + + + {% endif %} + {% endfor %} +
    Status + + + + Read status descriptions +
    The current status of your submission in arXiv moderation. Click to read all status descriptinos.
    +
    +
    IdentifierTitleCreatedActions
    {{ submission.status }}submit/{{ submission.submission_id }}{% if submission.metadata.title %} + {{ submission.metadata.title }} + {% else %} + Submission {{ submission.submission_id }} + {% endif %}{{ submission.created|timesince }} + {% if submission.status == submission.SUBMITTED and not submission.has_active_requests %} + + Unsubmit + Unsubmit submission id {{ submission.submission_id }} +
    Warning! Unsubmitting your paper will remove it from the publishing queue.
    +
    + {% else %} + + + Edit submission id {{ submission.submission_id }} + + {% endif %} + + Delete + Delete submission id {{ submission.submission_id }} + +
    + {% else %} +

    No submissions currently in progress

    + {% endif %} +
    +
    + +
    +

    Announced Articles

    +
    + {% if user_submissions|selectattr('is_announced')|list|length > 0 %} + + + + + + + + + + {% for submission in user_submissions %} + {% if submission.is_announced %} + + + + + + + {% endif %} + {% endfor %} +
    IdentifierPrimary ClassificationTitleActions
    {{ submission.arxiv_id }}{{ submission.primary_classification.category }}{{ submission.metadata.title }} + {% if submission.has_active_requests %} + {% for request in submission.active_user_requests %} +
    + {{ request.NAME }} {{ request.status }} + + Delete + Delete request id {{ request.request_id }} + +
    + {% endfor %} + {% else %} + + + + {% endif %} +
    + {% else %} +

    No announced articles

    + {% endif %} +
    +
    + +{% endblock %} diff --git a/submit/templates/submit/policy.html b/submit/templates/submit/policy.html new file mode 100644 index 0000000..7aa10e0 --- /dev/null +++ b/submit/templates/submit/policy.html @@ -0,0 +1,78 @@ +{% extends "submit/base.html" %} + +{% block title -%}Acknowledge Policy Statement{%- endblock title %} + +{% block within_content %} +
    +
    +

    Terms and Conditions - Scroll to read before proceeding

    +
    +

    Any person submitting a work for deposit in arXiv is required to assent to the following terms and conditions.

    + +

    Representations and Warranties

    +

    The Submitter makes the following representations and warranties:

    +
      +
    • The Submitter is an original author of the Work, or a proxy pre-approved by arXiv administrators acting on behalf of an original author.
    • +
    • If the Work was created by multiple authors, that all of them have consented to the submission of the Work to arXiv.
    • +
    • The Submitter has the right to include any third-party materials used in the Work.
    • +
    • The use of any third-party materials is consistent with scholarly and academic standards of proper citation and acknowledgement of sources.
    • +
    • The Work is not subject to any agreement, license or other claim that could interfere with the rights granted herein.
    • +
    • The distribution of the Work by arXiv will not violate any rights of any person or entity, including without limitation any rights of copyright, patent, trade secret, privacy, or any other rights, and it contains no defamatory, obscene, or other unlawful matter.
    • +
    • The Submitter has authority to make the statements, grant the rights, and take the actions described above.
    • +
    + +

    Grant of the License to arXiv

    +

    The Submitter grants to arXiv upon submission of the Work a non-exclusive, perpetual, and royalty-free license to include the Work in the arXiv repository and permit users to access, view, and make other uses of the work in a manner consistent with the services and objectives of arXiv. This License includes without limitation the right for arXiv to reproduce (e.g., upload, backup, archive, preserve, and allow online access), distribute (e.g., allow access), make available, and prepare versions of the Work (e.g., , abstracts, and metadata or text files, formats for persons with disabilities) in analog, digital, or other formats as arXiv may deem appropriate. + +

    Waiver of Rights and Indemnification

    +

    The Submitter waives the following claims on behalf of him/herself and all other authors:

    +
      +
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof, based upon the use of the Work by arXiv consistent with the License.
    • +
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof, based upon actions taken by any such party with respect to the Work, including without limitation decisions to include the Work in, or exclude the Work from, the repository; editorial decisions or changes affecting the Work, including the identification of Submitters and their affiliations or titles; the classification or characterization of the Work; the content of any metadata; the availability or lack of availability of the Work to researchers or other users of arXiv, including any decision to include the Work initially or remove or disable access.
    • +
    • Any rights related to the integrity of the Work under Moral Rights principles.
    • +
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof based upon any loss of content or disclosure of information provided in connection with a submission.
    • +
    • The Submitter will indemnify, defend, and hold harmless arXiv, Cornell University and its officers, employees, agents, and other affiliated parties from any loss, damage, or claim arising out of or related to: (a) any breach of any representations or warranties herein; (b) any failure by Submitter to perform any of Submitter’s obligations herein; and (c) any use of the Work as anticipated in the License and terms of submittal.
    • +
    +

    Management of Copyright

    +

    This grant to arXiv is a non-exclusive license and is not a grant of exclusive rights or a transfer of the copyright. The Submitter (and any other authors) retains their copyright and may enter into publication agreements or other arrangements, so long as they do not conflict with the ability of arXiv to exercise its rights under the License. arXiv has no obligation to protect or enforce any copyright in the Work, and arXiv has no obligation to respond to any permission requests or other inquiries regarding the copyright in or other uses of the Work.

    + +

    The Submitter may elect to make the Work available under one of the following Creative Commons licenses that the Submitter shall select at the time of submission: +

      +
    • Creative Commons Attribution license (CC BY 4.0)
    • +
    • Creative Commons Attribution-ShareAlike license (CC BY-SA 4.0)
    • +
    • Creative Commons Attribution-Noncommercial-ShareAlike license (CC BY-NC-SA 4.0)
    • +
    • Creative Commons Public Domain Dedication (CC0 1.0)
    • +
    + +

    If you wish to use a different CC license, then select arXiv's non-exclusive license to distribute in the arXiv submission process and indicate the desired Creative Commons license in the actual article. + The Creative Commons licenses are explained here:
    + https://creativecommons.org/licenses/.

    + +

    Metadata license

    +

    To the extent that the Submitter or arXiv has a copyright interest in metadata accompanying the submission, a Creative Commons CC0 1.0 Universal Public Domain Dedication will apply. Metadata includes title, author, abstract, and other information describing the Work.

    + +

    General Provisions

    +

    This Agreement will be governed by and construed in accordance with the substantive and procedural laws of the State of New York and the applicable federal law of the United States without reference to any conflicts of laws principles. The Submitter agrees that any action, suit, arbitration, or other proceeding arising out of or related to this Agreement must be commenced in the state or federal courts serving Tompkins County, New York. The Submitter hereby consents on behalf of the Submitter and any other authors to the personal jurisdiction of such courts.

    +
    + + +
    +
    + {{ form.csrf_token }} + {% if form.policy.errors %}
    {% endif %} +
    +
    +
    + {{ form.policy }} + {{ form.policy.label }} +
    + {% for error in form.policy.errors %} +

    {{ error }}

    + {% endfor %} +
    +
    + {% if form.policy.errors %}
    {% endif %} +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/replace.html b/submit/templates/submit/replace.html new file mode 100644 index 0000000..e0b9c35 --- /dev/null +++ b/submit/templates/submit/replace.html @@ -0,0 +1,36 @@ +{% extends "submit/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block content %} +

    Start a New Replacement

    + +{% if submission and submission.version >= 1 %} +

    Replacing arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    +{% endif %} + + + +

    Considerations when replacing an article:

    +
      +
    • You may use the Comments field to inform readers about the reason for the replacement.
    • +
    • A new version of the article will be generated.
    • +
    • After the fifth revision, papers will not be announced in the daily mailings.
    • +
    + +

    You can add a DOI, journal reference, or report number without submitting a replacement if that is the only change being made. To do so, cancel this replacement and submit a journal reference.

    + +
    + {{ form.csrf_token }} + + +
    + +{% endblock content %} diff --git a/submit/templates/submit/request_cross_list.html b/submit/templates/submit/request_cross_list.html new file mode 100644 index 0000000..c292b64 --- /dev/null +++ b/submit/templates/submit/request_cross_list.html @@ -0,0 +1,121 @@ +{%- extends "base/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{% block addl_head %} + + +{% endblock addl_head %} + +{% block content %} +

    Request Cross-List Classification

    +

    {% if primary %} + {{ submission.primary_classification.category }} + {% endif %} + {% for category in submission.secondary_categories %} + {{ category }} + {% endfor %} + arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    + +
    +
    +

    New cross-list(s)

    + {# This formset is used to send POST requests to REMOVE secondaries from the list. #} + {% if formset %} + {% for category, subform in formset.items() %} + +
    + {{ form.csrf_token }} +

    + {{ subform.operation }} + {% for cat in selected %} + + {% endfor %} + {{ subform.category()|safe }} + {{ subform.category.data }} {{ category|get_category_name }} + +

    +
    + {% endfor %} + {% endif %} + + {# This form is used to send POST requests to ADD secondaries to the list. #} +
    + {{ form.csrf_token }} + {{ form.operation }} + {% for cat in selected %} + + {% endfor %} + {% with field = form.category %} +
    +
    +
    + {% if field.errors %} + {{ field(class="is-danger")|safe }} + {% else %} + {{ field(**{"aria-labelledby": "crosslist"})|safe }} + {% endif %} +
    + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} +
    +
    + +
    +
    + {% endwith %} +
    +
    + +
    + +
    +
    + +{% endblock content %} diff --git a/submit/templates/submit/status.html b/submit/templates/submit/status.html new file mode 100644 index 0000000..8d6c777 --- /dev/null +++ b/submit/templates/submit/status.html @@ -0,0 +1,24 @@ +{% extends "submit/base.html" %} + +{% block title -%}{{ submission.status }}{%- endblock title %} + +{% block within_content %} +

    + This is for dev/debugging. We need to implement this page with a real + layout that communicates what's going on with a user's submission. +

    + +
    +{{ submission|asdict|pprint }}
    +
    + +{% for event in events %} +
  • + {{ event.NAMED.capitalize() }} {{ event.created|timesince }} ({{event.event_id}}) +
    +  {{ event|asdict|pprint }}
    +  
    +
  • +{% endfor %} + +{% endblock within_content %} diff --git a/submit/templates/submit/submit_macros.html b/submit/templates/submit/submit_macros.html new file mode 100644 index 0000000..57f0acf --- /dev/null +++ b/submit/templates/submit/submit_macros.html @@ -0,0 +1,41 @@ +{% macro submit_nav(submission_id) %} + + + +{% endmacro %} + +{% macro progress_bar(submission_id, workflow, this_stage) %} + +{% endmacro %} diff --git a/submit/templates/submit/testalerts.html b/submit/templates/submit/testalerts.html new file mode 100644 index 0000000..5648556 --- /dev/null +++ b/submit/templates/submit/testalerts.html @@ -0,0 +1 @@ +{% extends "submit/base.html" %} diff --git a/submit/templates/submit/tex-log-test.html b/submit/templates/submit/tex-log-test.html new file mode 100644 index 0000000..783237f --- /dev/null +++ b/submit/templates/submit/tex-log-test.html @@ -0,0 +1,84 @@ + + + + + +

    Testing Document

    +

    This is a snippet of TeX log done in plain old HTML, to very rapidly test CSS for log highlighting. It is also self-documenting for the list of terms to highlight. Can be removed if there is no use for it in also testing regexes or other log functions.

    +
    +

    Terms to highlight with info:
    + ~~ (*) ~~ /~+.*~+/g +

    +

    Terms to highlight with warning, case insensitive:
    + Warning
    + Citation (*) undefined
    + No (*) file
    + Unsupported
    + Unable
    + Ignore
    + Undefined
    +

    +

    Terms to highlight with danger, case insensitive:
    + !(any number repeating) or !==> /!+(==>)?/g
    + Error or [error] /\[?\berror\b\]?/gi
    + Failed
    + Emergency stop
    + File (*) not found
    + Not allowed
    + Does not exist
    +

    +

    Terms to highlight with fatal, case insensitive:
    + Fatal
    + Fatal (*) error /\bfatal\b[\W\w]*?\berror\b/gi
    +

    +
    +

    + [verbose]: pdflatex 'main.tex' failed.
    + [verbose]: 'htex' is not a valid TeX format; will ignore.
    + [verbose]: TEXMFCNF is unset.
    + [verbose]: ~~~~~~~~~~~ Running htex for the first time ~~~~~~~~
    + [verbose]: Running: "(export HOME=/tmp PATH=/texlive/2016/bin/arch:/bin; cd /submissions/2409425/ && tex 'main.tex' < /dev/null)" 2>&1
    + [verbose]: This is TeX, Version 3.14159265 (TeX Live 2016) (preloaded format=tex)
    + (./main.tex
    + ! Undefined control sequence.
    + l.1 \documentclass
    + [runningheads,a4paper]{llncs}
    + ?
    + ! Emergency stop.
    + l.1 \documentclass
    + [runningheads,a4paper]{llncs}
    + No pages of output.
    + Transcript written on main.log.
    +
    + [verbose]: tex 'main.tex' failed.
    + [verbose]: TEXMFCNF is unset.
    + [verbose]: ~~~~~~~~~~~ Running tex for the first time ~~~~~~~~
    + [verbose]: Running: "(export HOME=/tmp PATH=/texlive/2016/bin/arch:/bin; cd /submissions/2409425/ && tex 'main.tex' < /dev/null)" 2>&1
    + [verbose]: This is TeX, Version 3.14159265 (TeX Live 2016) (preloaded format=tex)
    + (./main.tex
    + ! Undefined control sequence.
    + l.1 \documentclass
    + [runningheads,a4paper]{llncs}
    + ?
    + ! Emergency stop.
    + l.1 \documentclass
    + [runningheads,a4paper]{llncs}
    + No pages of output.
    + Transcript written on main.log.
    +
    + [verbose]: Fatal error + [verbose]: tex 'main.tex' failed.
    + [verbose]: We failed utterly to process the TeX file 'main.tex'
    + [error]: Unable to sucessfully process tex files.
    + *** AutoTeX ABORTING ***
    +
    + [verbose]: AutoTeX returned error: Unable to sucessfully process tex files. +

    + + diff --git a/submit/templates/submit/verify_user.html b/submit/templates/submit/verify_user.html new file mode 100644 index 0000000..2410dae --- /dev/null +++ b/submit/templates/submit/verify_user.html @@ -0,0 +1,97 @@ +{% extends "submit/base.html" %} + +{% block title -%}Verify Your Contact Information{%- endblock title %} + +{% block more_notifications -%} + {% if submitter.endorsements|endorsetype == 'Some' %} +
    +
    +

    Endorsements

    +
    +
    +
    + +
    +

    You are currently endorsed for:

    +
      + {% for endorsement in submitter.endorsements %} +
    • {{ endorsement.display }}
    • + {% endfor %} +
    +

    If you wish to submit to a category other than those listed, you will need to seek endorsement before submitting.

    +
    +
    + {% endif %} + {% if submitter.endorsements|endorsetype == 'None' %} +
    +
    +

    Endorsements

    +
    +
    +
    + +
    +

    Your account does not currently have any endorsed categories. You will need to seek endorsement before submitting.

    +
    +
    + {% endif %} +{%- endblock more_notifications %} + +{% block within_content %} +

    Check this information carefully! A current email address is required to complete your submission. The name and email address of the submitter will be viewable to registered arXiv users.

    + + +
    + {{ form.csrf_token }} +
    + {% if form.verify_user.errors %}
    {% endif %} +
    +
    + {{ form.verify_user }} + {{ form.verify_user.label }} +
    + {% for error in form.verify_user.errors %} +

    {{ error }}

    + {% endfor %} +
    + {% if form.verify_user.errors %}
    {% endif %} +
    + {{ submit_macros.submit_nav(submission_id) }} +
    +{% endblock within_content %} diff --git a/submit/templates/submit/withdraw.html b/submit/templates/submit/withdraw.html new file mode 100644 index 0000000..8715fc8 --- /dev/null +++ b/submit/templates/submit/withdraw.html @@ -0,0 +1,124 @@ +{%- extends "base/base.html" %} + +{% import "submit/submit_macros.html" as submit_macros %} + +{%- macro comments_preview(comments, withdrawal_reason) -%} +{{ comments }} Withdrawn: {{ withdrawal_reason}} +{% endmacro %} + +{% block addl_head %} + + +{% endblock addl_head %} + +{% block content %} +

    Request Article Withdrawal

    +

    arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    + + {% if require_confirmation and not confirmed %} +
    +

    This preview shows the abstract page as it will be updated. Please + check carefully to ensure that it is correct. You can continue to + make changes until you are satisfied with the result.

    +
    + +
    + {{ macros.abs( + submission.arxiv_id, + submission.metadata.title, + submission.metadata.authors_display, + submission.metadata.abstract, + submission.created, + submission.primary_classification.category, + comments = comments_preview(submission.metadata.comments, form.withdrawal_reason.data), + msc_class = submission.metadata.msc_class, + acm_class = submission.metadata.acm_class, + journal_ref = submission.metadata.journal_ref, + doi = submission.metadata.doi, + report_num = submission.metadata.report_num, + version = submission.version, + submission_history = [], + secondary_categories = submission.secondary_categories) }} +
    + {% else %} + + +

    Considerations for making a withdrawal request for an article:

    +
      +
    1. The paper cannot be completely removed. Previous versions will remain accessible.
      See help pages on: withdrawals, versions and licenses.
    2. +
    3. Inappropriate withdrawal requests will be denied.
    4. +
    5. It is not appropriate to withdraw a paper because it is published or submitted to a journal. Instead you could submit a journal-ref.
    6. +
    7. It is not appropriate to withdraw a paper because it is being updated. Instead you could submit a replacement.
    8. +
    9. You may modify the abstract field only if the comments field is inadequate for your explanation. Removing the abstract totally is inappropriate and will result in a denial of your withdrawal request.
    10. +
    + {% endif %} + +
    +
    +
    + {{ form.csrf_token }} + {% with field = form.withdrawal_reason %} +
    +
    + + {% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + {% if field.description %} +

    + {{ field.description|safe }} +

    + {% endif %} + {% if field.errors %} + {{ field(class="textarea is-danger")|safe }} + {% else %} + {{ field(class="textarea")|safe }} + {% endif %} +
    +
    + {% endwith %} + +
    +
    + +
    +
    + +
    + +{% endblock content %} diff --git a/submit/tests/__init__.py b/submit/tests/__init__.py new file mode 100644 index 0000000..ad04188 --- /dev/null +++ b/submit/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the application as a whole.""" diff --git a/submit/tests/csrf_util.py b/submit/tests/csrf_util.py new file mode 100644 index 0000000..2a15bdf --- /dev/null +++ b/submit/tests/csrf_util.py @@ -0,0 +1,24 @@ +import re + +CSRF_PATTERN = (r'\') + + +def parse_csrf_token(input): + """Gets the csrf token from a WTForm. + + This can usually be passed back to the web app as the field 'csrf_token' """ + try: + txt = None + if hasattr(input, 'text'): + txt = input.text + elif hasattr(input, 'data'): + txt = input.data.decode('utf-8') + else: + txt = input + + + return re.search(CSRF_PATTERN, txt).group(1) + except AttributeError: + raise Exception('Could not find CSRF token') + return token diff --git a/submit/tests/mock_filemanager.py b/submit/tests/mock_filemanager.py new file mode 100644 index 0000000..9cf9693 --- /dev/null +++ b/submit/tests/mock_filemanager.py @@ -0,0 +1,109 @@ +"""Mock file management app for testing and development.""" + +from datetime import datetime +import json +from flask import Flask, Blueprint, jsonify, request +from werkzeug.exceptions import RequestEntityTooLarge, BadRequest, \ + Unauthorized, Forbidden, NotFound + +from http import HTTPStatus as status + +blueprint = Blueprint('filemanager', __name__) + +UPLOADS = { + 1: dict( + checksum='a1s2d3f4', + size=593920, + files={ + 'thebestfile.pdf': dict( + path='', + name='thebestfile.pdf', + file_type='PDF', + modified=datetime.now().isoformat(), + size=20505, + ancillary=False, + errors=[] + ) + }, + errors=[] + ), + 2: dict(checksum='4f3d2s1a', size=0, files={}, errors=[]) +} + + +def _set_upload(upload_id, payload): + upload_status = dict(payload) + upload_status['files'] = { + f'{f["path"]}{f["name"]}': f for f in upload_status['files'] + } + UPLOADS[upload_id] = upload_status + return _get_upload(upload_id) + + +def _get_upload(upload_id): + try: + status = dict(UPLOADS[upload_id]) + except KeyError: + raise NotFound('Nope') + if type(status['files']) is dict: + status['files'] = list(status['files'].values()) + status['identifier'] = upload_id + return status + + +def _add_file(upload_id, file_data): + UPLOADS[upload_id]['files'][f'{file_data["path"]}{file_data["name"]}'] = file_data + return _get_upload(upload_id) + + +@blueprint.route('/status') +def service_status(): + """Mock implementation of service status route.""" + return jsonify({'status': 'ok'}) + + +@blueprint.route('/', methods=['POST']) +def upload_package(): + """Mock implementation of upload route.""" + if 'file' not in request.files: + raise BadRequest('No file') + content = request.files['file'].read() + if len(content) > 80000: # Arbitrary limit. + raise RequestEntityTooLarge('Nope!') + if 'Authorization' not in request.headers: + raise Unauthorized('Nope!') + if request.headers['Authorization'] != '!': # Arbitrary value. + raise Forbidden('No sir!') + + payload = json.loads(content) # This is specific to the mock. + # Not sure what the response will look like yet. + upload_id = max(UPLOADS.keys()) + 1 + upload_status = _set_upload(upload_id, payload) + return jsonify(upload_status), status.CREATED + + +@blueprint.route('/', methods=['POST']) +def add_file(upload_id): + """Mock implementation of file upload route.""" + upload_status = _get_upload(upload_id) + if 'file' not in request.files: + raise BadRequest('{"error": "No file"}') + content = request.files['file'].read() + if len(content) > 80000: # Arbitrary limit. + raise RequestEntityTooLarge('{"error": "Nope!"}') + if 'Authorization' not in request.headers: + raise Unauthorized('{"error": "No chance"}') + if request.headers['Authorization'] != '!': # Arbitrary value. + raise Forbidden('{"error": "No sir!"}') + + # Not sure what the response will look like yet. + payload = json.loads(content) + upload_status = _add_file(upload_id, payload) + return jsonify(upload_status), status.CREATED + + +def create_fm_app(): + """Generate a mock file management app.""" + app = Flask('filemanager') + app.register_blueprint(blueprint) + return app diff --git a/submit/tests/test_domain.py b/submit/tests/test_domain.py new file mode 100644 index 0000000..6f624d9 --- /dev/null +++ b/submit/tests/test_domain.py @@ -0,0 +1,65 @@ +"""Tests for the ui-app UI domain classes.""" + +from unittest import TestCase, mock +from datetime import datetime +from arxiv.submission.domain import Submission, User +# Commenting out for now - there is nothing that runs below - everything +# is commented out - dlf2 +#from .. import domain + + +# class TestSubmissionStage(TestCase): +# """Tests for :class:`domain.SubmissionStage`.""" +# +# def test_initial_stage(self): +# """Nothing has been done yet.""" +# ui-app = Submission( +# creator=User('12345', 'foo@user.edu'), +# owner=User('12345', 'foo@user.edu'), +# created=datetime.now() +# ) +# submission_stage = domain.SubmissionStage(ui-app) +# self.assertEqual(submission_stage.current_stage, None, +# "No stage is complete.") +# self.assertEqual(submission_stage.next_stage, +# domain.SubmissionStage.ORDER[0][0], +# "The next stage is the first stage.") +# self.assertIsNone(submission_stage.previous_stage, +# "There is no previous stage.") +# +# self.assertTrue( +# submission_stage.before(domain.Stages.POLICY) +# ) +# self.assertTrue( +# submission_stage.on_or_before(domain.Stages.POLICY) +# ) +# self.assertTrue( +# submission_stage.on_or_before(submission_stage.current_stage) +# ) +# self.assertFalse( +# submission_stage.after(domain.Stages.POLICY) +# ) +# self.assertFalse( +# submission_stage.on_or_after(domain.Stages.POLICY) +# ) +# self.assertTrue( +# submission_stage.on_or_after(submission_stage.current_stage) +# ) +# +# def test_verify(self): +# """The user has verified their information.""" +# ui-app = Submission( +# creator=User('12345', 'foo@user.edu'), +# owner=User('12345', 'foo@user.edu'), +# created=datetime.now(), +# submitter_contact_verified=True +# ) +# submission_stage = domain.SubmissionStage(ui-app) +# self.assertEqual(submission_stage.previous_stage, None, +# "There is nothing before the verify user stage") +# self.assertEqual(submission_stage.next_stage, +# domain.Stages.AUTHORSHIP, +# "The next stage is to indicate authorship.") +# self.assertEqual(submission_stage.current_stage, +# domain.Stages.VERIFY_USER, +# "The current completed stage is verify user.") diff --git a/submit/tests/test_workflow.py b/submit/tests/test_workflow.py new file mode 100644 index 0000000..b9799c2 --- /dev/null +++ b/submit/tests/test_workflow.py @@ -0,0 +1,762 @@ +"""Tests for the ui-app application as a whole.""" + +import os +import re +import tempfile +from unittest import TestCase, mock +from urllib.parse import urlparse + +from arxiv_auth.helpers import generate_token + +from submit.factory import create_ui_web_app +from arxiv.submission.services import classic +from arxiv_auth.auth import scopes +from arxiv_auth.domain import Category +from http import HTTPStatus as status +from arxiv.submission.domain.event import * +from arxiv.submission.domain.agent import User +from arxiv.submission.domain.submission import Author, SubmissionContent +from arxiv.submission import save +from .csrf_util import parse_csrf_token + + +# TODO: finish building out this test suite. The current tests run up to +# file upload. Once the remaining stages have stabilized, this should have +# tests from end to end. +# TODO: add a test where the user tries to jump around in the workflow, and +# verify that stage completion order is enforced. +class TestSubmissionWorkflow(TestCase): + """Tests that progress through the ui-app workflow in various ways.""" + + @mock.patch('arxiv.submission.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create an application instance.""" + self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) + os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET')) + _, self.db = tempfile.mkstemp(suffix='.db') + self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[ + Category('astro-ph.GA'), + Category('astro-ph.CO'), + ]) + self.headers = {'Authorization': self.token} + self.client = self.app.test_client() + with self.app.app_context(): + classic.create_all() + + def tearDown(self): + """Remove the temporary database.""" + os.remove(self.db) + + def _parse_csrf_token(self, response): + try: + return parse_csrf_token(response) + except AttributeError: + self.fail('Could not find CSRF token') + + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_create_submission(self): + """User creates a new ui-app, and proceeds up to upload stage.""" + # Get the ui-app creation page. + response = self.client.get('/', headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + token = self._parse_csrf_token(response) + + # Create a ui-app. + response = self.client.post('/', + data={'new': 'new', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This should be the verify_user + # stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('verify_user', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn( + b'By checking this box, I verify that my user information is' + b' correct.', + response.data + ) + token = self._parse_csrf_token(response) + upload_id, _ = next_page.path.lstrip('/').split('/verify_user', 1) + + # Make sure that the user cannot skip forward to subsequent steps. + response = self.client.get(f'/{upload_id}/file_upload') + self.assertEqual(response.status_code, status.FOUND) + + response = self.client.get(f'/{upload_id}/final_preview') + self.assertEqual(response.status_code, status.FOUND) + + response = self.client.get(f'/{upload_id}/add_optional_metadata') + self.assertEqual(response.status_code, status.FOUND) + + # Submit the verify user page. + response = self.client.post(next_page.path, + data={'verify_user': 'y', + 'action': 'next', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the authorship stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('authorship', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn(b'I am an author of this paper', response.data) + token = self._parse_csrf_token(response) + + # Submit the authorship page. + response = self.client.post(next_page.path, + data={'authorship': 'y', + 'action': 'next', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the license stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('license', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn(b'Select a License', response.data) + token = self._parse_csrf_token(response) + + # Submit the license page. + selected = "http://creativecommons.org/licenses/by-sa/4.0/" + response = self.client.post(next_page.path, + data={'license': selected, + 'action': 'next', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the policy stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('policy', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn( + b'By checking this box, I agree to the policies listed on' + b' this page', + response.data + ) + token = self._parse_csrf_token(response) + + # Submit the policy page. + response = self.client.post(next_page.path, + data={'policy': 'y', + 'action': 'next', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the primary category stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('classification', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn(b'Choose a Primary Classification', response.data) + token = self._parse_csrf_token(response) + + # Submit the primary category page. + response = self.client.post(next_page.path, + data={'category': 'astro-ph.GA', + 'action': 'next', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the cross list stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('cross', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn(b'Choose Cross-List Classifications', response.data) + token = self._parse_csrf_token(response) + + # Submit the cross-list category page. + response = self.client.post(next_page.path, + data={'category': 'astro-ph.CO', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.OK) + + response = self.client.post(next_page.path, + data={'action':'next'}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This is the file upload stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('upload', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn(b'Upload Files', response.data) + token = self._parse_csrf_token(response) + + +class TestEndorsementMessaging(TestCase): + """Verify submitter is shown appropriate messaging about endoresement.""" + + def setUp(self): + """Create an application instance.""" + self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) + os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) + _, self.db = tempfile.mkstemp(suffix='.db') + self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' + self.client = self.app.test_client() + with self.app.app_context(): + classic.create_all() + + def tearDown(self): + """Remove the temporary database.""" + os.remove(self.db) + + def _parse_csrf_token(self, response): + try: + return parse_csrf_token(response) + except AttributeError: + self.fail('Could not find CSRF token') + return token + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_no_endorsements(self): + """User is not endorsed (auto or otherwise) for anything.""" + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[]) + self.headers = {'Authorization': self.token} + + # Get the ui-app creation page. + response = self.client.get('/', headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + token = self._parse_csrf_token(response) + + # Create a ui-app. + response = self.client.post('/', + data={'new': 'new', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This should be the verify_user + # stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('verify_user', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn( + b'Your account does not currently have any endorsed categories.', + response.data, + 'User should be informed that they have no endorsements.' + ) + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_some_categories(self): + """User is endorsed (auto or otherwise) for some categories.""" + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[Category("cs.DL"), + Category("cs.AI")]) + self.headers = {'Authorization': self.token} + + # Get the ui-app creation page. + response = self.client.get('/', headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + token = self._parse_csrf_token(response) + + # Create a ui-app. + response = self.client.post('/', + data={'new': 'new', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This should be the verify_user + # stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('verify_user', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn( + b'You are currently endorsed for', + response.data, + 'User should be informed that they have some endorsements.' + ) + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_some_archives(self): + """User is endorsed (auto or otherwise) for some whole archives.""" + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[Category("cs.*"), + Category("math.*")]) + self.headers = {'Authorization': self.token} + + # Get the ui-app creation page. + response = self.client.get('/', headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + token = self._parse_csrf_token(response) + + # Create a ui-app. + response = self.client.post('/', + data={'new': 'new', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This should be the verify_user + # stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('verify_user', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertIn( + b'You are currently endorsed for', + response.data, + 'User should be informed that they have some endorsements.' + ) + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_all_endorsements(self): + """User is endorsed for everything.""" + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=["*.*"]) + self.headers = {'Authorization': self.token} + + # Get the ui-app creation page. + response = self.client.get('/', headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + token = self._parse_csrf_token(response) + + # Create a ui-app. + response = self.client.post('/', + data={'new': 'new', + 'csrf_token': token}, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + # Get the next page in the process. This should be the verify_user + # stage. + next_page = urlparse(response.headers['Location']) + self.assertIn('verify_user', next_page.path) + response = self.client.get(next_page.path, headers=self.headers) + self.assertNotIn( + b'Your account does not currently have any endorsed categories.', + response.data, + 'User should see no messaging about endorsement.' + ) + self.assertNotIn( + b'You are currently endorsed for', + response.data, + 'User should see no messaging about endorsement.' + ) + + +class TestJREFWorkflow(TestCase): + """Tests that progress through the JREF workflow.""" + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create an application instance.""" + self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) + os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) + _, self.db = tempfile.mkstemp(suffix='.db') + self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' + self.user = User('1234', 'foo@bar.com', endorsements=['astro-ph.GA']) + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[ + Category('astro-ph.GA'), + Category('astro-ph.CO'), + ]) + self.headers = {'Authorization': self.token} + self.client = self.app.test_client() + + # Create and announce a ui-app. + with self.app.app_context(): + classic.create_all() + session = classic.current_session() + + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + self.submission, _ = save( + CreateSubmission(creator=self.user), + ConfirmContactInformation(creator=self.user), + ConfirmAuthorship(creator=self.user, submitter_is_author=True), + SetLicense( + creator=self.user, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.user), + SetPrimaryClassification(creator=self.user, + category='astro-ph.GA'), + SetUploadPackage( + creator=self.user, + checksum="a9s9k342900skks03330029k", + source_format=SubmissionContent.Format.TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=59392, + ), + SetTitle(creator=self.user, title='foo title'), + SetAbstract(creator=self.user, abstract='ab stract' * 20), + SetComments(creator=self.user, comments='indeed'), + SetReportNumber(creator=self.user, report_num='the number 12'), + SetAuthors( + creator=self.user, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.user) + ) + + # announced! + db_submission = session.query(classic.models.Submission) \ + .get(self.submission.submission_id) + db_submission.status = classic.models.Submission.ANNOUNCED + db_document = classic.models.Document(paper_id='1234.5678') + db_submission.doc_paper_id = '1234.5678' + db_submission.document = db_document + session.add(db_submission) + session.add(db_document) + session.commit() + + self.submission_id = self.submission.submission_id + + def tearDown(self): + """Remove the temporary database.""" + os.remove(self.db) + + def _parse_csrf_token(self, response): + try: + return parse_csrf_token(response) + except AttributeError: + self.fail('Could not find CSRF token') + return token + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_create_submission(self): + """User creates a new ui-app, and proceeds up to upload stage.""" + # Get the JREF page. + endpoint = f'/{self.submission_id}/jref' + response = self.client.get(endpoint, headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + self.assertIn(b'Journal reference', response.data) + token = self._parse_csrf_token(response) + + # Set the DOI, journal reference, report number. + request_data = {'doi': '10.1000/182', + 'journal_ref': 'foo journal 1992', + 'report_num': 'abc report 42', + 'csrf_token': token} + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + self.assertIn(b'Confirm and Submit', response.data) + token = self._parse_csrf_token(response) + + request_data['confirmed'] = True + request_data['csrf_token'] = token + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + with self.app.app_context(): + session = classic.current_session() + # What happened. + db_submission = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.doc_paper_id == '1234.5678') + self.assertEqual(db_submission.count(), 2, + "Creates a second row for the JREF") + + +class TestWithdrawalWorkflow(TestCase): + """Tests that progress through the withdrawal request workflow.""" + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create an application instance.""" + self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) + os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) + _, self.db = tempfile.mkstemp(suffix='.db') + self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' + self.user = User('1234', 'foo@bar.com', + endorsements=['astro-ph.GA', 'astro-ph.CO']) + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[ + Category('astro-ph.GA'), + Category('astro-ph.CO'), + ]) + self.headers = {'Authorization': self.token} + self.client = self.app.test_client() + + # Create and announce a ui-app. + with self.app.app_context(): + classic.create_all() + session = classic.current_session() + + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + self.submission, _ = save( + CreateSubmission(creator=self.user), + ConfirmContactInformation(creator=self.user), + ConfirmAuthorship(creator=self.user, submitter_is_author=True), + SetLicense( + creator=self.user, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.user), + SetPrimaryClassification(creator=self.user, + category='astro-ph.GA'), + SetUploadPackage( + creator=self.user, + checksum="a9s9k342900skks03330029k", + source_format=SubmissionContent.Format.TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=59392, + ), + SetTitle(creator=self.user, title='foo title'), + SetAbstract(creator=self.user, abstract='ab stract' * 20), + SetComments(creator=self.user, comments='indeed'), + SetReportNumber(creator=self.user, report_num='the number 12'), + SetAuthors( + creator=self.user, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.user) + ) + + # announced! + db_submission = session.query(classic.models.Submission) \ + .get(self.submission.submission_id) + db_submission.status = classic.models.Submission.ANNOUNCED + db_document = classic.models.Document(paper_id='1234.5678') + db_submission.doc_paper_id = '1234.5678' + db_submission.document = db_document + session.add(db_submission) + session.add(db_document) + session.commit() + + self.submission_id = self.submission.submission_id + + def tearDown(self): + """Remove the temporary database.""" + os.remove(self.db) + + def _parse_csrf_token(self, response): + try: + return parse_csrf_token(response) + except AttributeError: + self.fail('Could not find CSRF token') + return token + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_request_withdrawal(self): + """User requests withdrawal of a announced ui-app.""" + # Get the JREF page. + endpoint = f'/{self.submission_id}/withdraw' + response = self.client.get(endpoint, headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + self.assertIn(b'Request withdrawal', response.data) + token = self._parse_csrf_token(response) + + # Set the withdrawal reason, but make it huge. + request_data = {'withdrawal_reason': 'This is the reason' * 400, + 'csrf_token': token} + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.OK) + token = self._parse_csrf_token(response) + + # Set the withdrawal reason to something reasonable (ha). + request_data = {'withdrawal_reason': 'This is the reason', + 'csrf_token': token} + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + self.assertIn(b'Confirm and Submit', response.data) + token = self._parse_csrf_token(response) + + # Confirm the withdrawal request. + request_data['confirmed'] = True + request_data['csrf_token'] = token + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + with self.app.app_context(): + session = classic.current_session() + # What happened. + db_submissions = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.doc_paper_id == '1234.5678') + self.assertEqual(db_submissions.count(), 2, + "Creates a second row for the withdrawal") + db_submission = db_submissions \ + .order_by(classic.models.Submission.submission_id.desc()) \ + .first() + self.assertEqual(db_submission.type, + classic.models.Submission.WITHDRAWAL) + + +class TestUnsubmitWorkflow(TestCase): + """Tests that progress through the unsubmit workflow.""" + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def setUp(self): + """Create an application instance.""" + self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) + os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) + _, self.db = tempfile.mkstemp(suffix='.db') + self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' + self.user = User('1234', 'foo@bar.com', endorsements=['astro-ph.GA']) + self.token = generate_token('1234', 'foo@bar.com', 'foouser', + scope=[scopes.CREATE_SUBMISSION, + scopes.EDIT_SUBMISSION, + scopes.VIEW_SUBMISSION, + scopes.READ_UPLOAD, + scopes.WRITE_UPLOAD, + scopes.DELETE_UPLOAD_FILE], + endorsements=[ + Category('astro-ph.GA'), + Category('astro-ph.CO'), + ]) + self.headers = {'Authorization': self.token} + self.client = self.app.test_client() + + # Create a finalized ui-app. + with self.app.app_context(): + classic.create_all() + session = classic.current_session() + + cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' + self.submission, _ = save( + CreateSubmission(creator=self.user), + ConfirmContactInformation(creator=self.user), + ConfirmAuthorship(creator=self.user, submitter_is_author=True), + SetLicense( + creator=self.user, + license_uri=cc0, + license_name='CC0 1.0' + ), + ConfirmPolicy(creator=self.user), + SetPrimaryClassification(creator=self.user, + category='astro-ph.GA'), + SetUploadPackage( + creator=self.user, + checksum="a9s9k342900skks03330029k", + source_format=SubmissionContent.Format.TEX, + identifier=123, + uncompressed_size=593992, + compressed_size=59392, + ), + SetTitle(creator=self.user, title='foo title'), + SetAbstract(creator=self.user, abstract='ab stract' * 20), + SetComments(creator=self.user, comments='indeed'), + SetReportNumber(creator=self.user, report_num='the number 12'), + SetAuthors( + creator=self.user, + authors=[Author( + order=0, + forename='Bob', + surname='Paulson', + email='Robert.Paulson@nowhere.edu', + affiliation='Fight Club' + )] + ), + FinalizeSubmission(creator=self.user) + ) + + self.submission_id = self.submission.submission_id + + def tearDown(self): + """Remove the temporary database.""" + os.remove(self.db) + + def _parse_csrf_token(self, response): + try: + return parse_csrf_token(response) + except AttributeError: + self.fail('Could not find CSRF token') + return token + + @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) + def test_unsubmit_submission(self): + """User unsubmits a ui-app.""" + # Get the unsubmit confirmation page. + endpoint = f'/{self.submission_id}/unsubmit' + response = self.client.get(endpoint, headers=self.headers) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(response.content_type, 'text/html; charset=utf-8') + self.assertIn(b'Unsubmit This Submission', response.data) + token = self._parse_csrf_token(response) + + # Confirm the ui-app should be unsubmitted + request_data = {'confirmed': True, 'csrf_token': token} + response = self.client.post(endpoint, data=request_data, + headers=self.headers) + self.assertEqual(response.status_code, status.SEE_OTHER) + + with self.app.app_context(): + session = classic.current_session() + # What happened. + db_submission = session.query(classic.models.Submission) \ + .filter(classic.models.Submission.submission_id == + self.submission_id).first() + self.assertEqual(db_submission.status, + classic.models.Submission.NOT_SUBMITTED, "") diff --git a/submit/util.py b/submit/util.py new file mode 100644 index 0000000..6cbd881 --- /dev/null +++ b/submit/util.py @@ -0,0 +1,157 @@ +"""Utilities and helpers for the :mod:`submit` application.""" + +from typing import Optional, Tuple, List +from datetime import datetime +from werkzeug.exceptions import NotFound +from retry import retry + +from arxiv.base import logging +from arxiv.base.globals import get_application_global +from arxiv.submission.services.classic.exceptions import Unavailable +import arxiv.submission as events + +logger = logging.getLogger(__name__) +logger.propagate = False + + +@retry(tries=5, delay=0.5, backoff=3, exceptions=Unavailable) +def load_submission(submission_id: Optional[int]) \ + -> Tuple[events.domain.Submission, List[events.domain.Event]]: + """ + Load a ui-app by ID. + + Parameters + ---------- + submission_id : int + + Returns + ------- + :class:`events.domain.Submission` + + Raises + ------ + :class:`werkzeug.exceptions.NotFound` + Raised when there is no ui-app with the specified ID. + + """ + if submission_id is None: + logger.debug('No ui-app ID') + raise NotFound('No such ui-app.') + + g = get_application_global() + if g is None or f'submission_{submission_id}' not in g: + try: + submission, submission_events = events.load(submission_id) + except events.exceptions.NoSuchSubmission as e: + raise NotFound('No such ui-app.') from e + if g is not None: + setattr(g, f'submission_{submission_id}', + (submission, submission_events)) + if g is not None: + return getattr(g, f'submission_{submission_id}') + return submission, submission_events + + +def tidy_filesize(size: int) -> str: + """ + Convert upload size to human readable form. + + Decision to use powers of 10 rather than powers of 2 to stay compatible + with Jinja filesizeformat filter with binary=false setting that we are + using in file_upload template. + + Parameter: size in bytes + Returns: formatted string of size in units up through GB + + """ + units = ["B", "KB", "MB", "GB"] + if size == 0: + return "0B" + if size > 1000000000: + return '{} {}'.format(size, units[3]) + units_index = 0 + while size > 1000: + units_index += 1 + size = round(size / 1000, 3) + return '{} {}'.format(size, units[units_index]) + + +# TODO: remove me! +def announce_submission(submission_id: int) -> None: + """WARNING WARNING WARNING this is for testing purposes only.""" + dbss = events.services.classic._get_db_submission_rows(submission_id) + head = sorted([o for o in dbss if o.is_new_version()], key=lambda o: o.submission_id)[-1] + session = events.services.classic.current_session() + if not head.is_announced(): + head.status = events.services.classic.models.Submission.ANNOUNCED + if head.document is None: + paper_id = datetime.now().strftime('%s')[-4:] \ + + "." \ + + datetime.now().strftime('%s')[-5:] + head.document = \ + events.services.classic.models.Document(paper_id=paper_id) + head.doc_paper_id = paper_id + session.add(head) + session.commit() + + +# TODO: remove me! +def place_on_hold(submission_id: int) -> None: + """WARNING WARNING WARNING this is for testing purposes only.""" + dbss = events.services.classic._get_db_submission_rows(submission_id) + i = events.services.classic._get_head_idx(dbss) + head = dbss[i] + session = events.services.classic.current_session() + if head.is_announced() or head.is_on_hold(): + return + head.status = events.services.classic.models.Submission.ON_HOLD + session.add(head) + session.commit() + + +# TODO: remove me! +def apply_cross(submission_id: int) -> None: + session = events.services.classic.current_session() + dbss = events.services.classic._get_db_submission_rows(submission_id) + i = events.services.classic._get_head_idx(dbss) + for dbs in dbss[:i]: + if dbs.is_crosslist(): + dbs.status = events.services.classic.models.Submission.ANNOUNCED + session.add(dbs) + session.commit() + + +# TODO: remove me! +def reject_cross(submission_id: int) -> None: + session = events.services.classic.current_session() + dbss = events.services.classic._get_db_submission_rows(submission_id) + i = events.services.classic._get_head_idx(dbss) + for dbs in dbss[:i]: + if dbs.is_crosslist(): + dbs.status = events.services.classic.models.Submission.REMOVED + session.add(dbs) + session.commit() + + +# TODO: remove me! +def apply_withdrawal(submission_id: int) -> None: + session = events.services.classic.current_session() + dbss = events.services.classic._get_db_submission_rows(submission_id) + i = events.services.classic._get_head_idx(dbss) + for dbs in dbss[:i]: + if dbs.is_withdrawal(): + dbs.status = events.services.classic.models.Submission.ANNOUNCED + session.add(dbs) + session.commit() + + +# TODO: remove me! +def reject_withdrawal(submission_id: int) -> None: + session = events.services.classic.current_session() + dbss = events.services.classic._get_db_submission_rows(submission_id) + i = events.services.classic._get_head_idx(dbss) + for dbs in dbss[:i]: + if dbs.is_withdrawal(): + dbs.status = events.services.classic.models.Submission.REMOVED + session.add(dbs) + session.commit() diff --git a/submit/workflow/__init__.py b/submit/workflow/__init__.py new file mode 100644 index 0000000..2fe1788 --- /dev/null +++ b/submit/workflow/__init__.py @@ -0,0 +1,150 @@ +"""Defines ui-app stages and workflows supported by this UI.""" + +from typing import Iterable, Optional, Callable, List, Iterator, Union + +from arxiv.submission.domain import Submission +from dataclasses import dataclass, field + +from . import stages +from .stages import Stage + + +@dataclass +class WorkflowDefinition: + name: str + order: List[Stage] = field(default_factory=list) + confirmation: Stage = None + + def __iter__(self) -> Iterator[Stage]: + """Iterate over stages in this workflow.""" + for stage in self.order: + yield stage + + def iter_prior(self, stage: Stage) -> Iterable[Stage]: + """Iterate over stages in this workflow up to a particular stage.""" + for prior_stage in self.order: + if prior_stage == stage: + return + yield prior_stage + + def next_stage(self, stage: Optional[Stage]) -> Optional[Stage]: + """Get the next stage.""" + if stage is None: + return None + idx = self.order.index(stage) + if idx + 1 >= len(self.order): + return None + return self.order[idx + 1] + + def previous_stage(self, stage: Optional[Stage]) -> Optional[Stage]: + """Get the previous stage.""" + if stage is None: + return None + idx = self.order.index(stage) + if idx == 0: + return None + return self.order[idx - 1] + + def stage_from_endpoint(self, endpoint: str) -> Stage: + """Get the :class:`.Stage` for an endpoint.""" + for stage in self.order: + if stage.endpoint == endpoint: + return stage + raise ValueError(f'No stage for endpoint: {endpoint}') + return self.order[0] # mypy + + def index(self, stage: Union[type, Stage, str]) -> int: + if stage in self.order: + return self.order.index(stage) + if isinstance(stage, type) and issubclass(stage, Stage): + for idx, st in enumerate(self.order): + if issubclass(st.__class__, stage): + return idx + raise ValueError(f"{stage} not In workflow") + + if isinstance(stage, str): # it could be classname, stage label + for idx, wstg in self.order: + if(wstg.label == stage + or wstg.__class__.__name__ == stage): + return idx + + raise ValueError(f"Should be subclass of Stage, classname or stage" + f"instance. Cannot call with {stage} of type " + f"{type(stage)}") + + def __getitem__(self, query: Union[type, Stage, str, int, slice])\ + -> Union[Optional[Stage], List[Stage]]: + if isinstance(query, slice): + return self.order.__getitem__(query) + else: + return self.get_stage(query) + + def get_stage(self, query: Union[type, Stage, str, int])\ + -> Optional[Stage]: + """Get the stage object from this workflow for Class, class name, + stage label, endpoint or index in order """ + if query is None: + return None + if isinstance(query, type): + if issubclass(query, Stage): + stages = [st for st in self.order if issubclass( + st.__class__, query)] + if len(stages) > 0: + return stages[0] + else: + return None + else: + raise ValueError("Cannot call get_stage with non-Stage class") + if isinstance(query, int): + if query >= len(self.order) or query < 0: + return None + else: + return self.order[query] + + if isinstance(query, str): + # it could be classname, stage label or stage endpoint + for stage in self.order: + if(stage.label == query + or stage.__class__.__name__ == query + or stage.endpoint == query): + return stage + return None + if query in self.order: + return self[self.order.index(query)] + raise ValueError("query should be Stage class or class name or " + f"endpoint or lable str or int. Not {type(query)}") + + +SubmissionWorkflow = WorkflowDefinition( + 'SubmissionWorkflow', + [stages.VerifyUser(), + stages.Authorship(), + stages.License(), + stages.Policy(), + stages.Classification(), + stages.CrossList(required=False, must_see=True), + stages.FileUpload(), + stages.Process(), + stages.Metadata(), + stages.OptionalMetadata(required=False, must_see=True), + stages.FinalPreview() + ], + stages.Confirm() +) +"""Workflow for new submissions.""" + +ReplacementWorkflow = WorkflowDefinition( + 'ReplacementWorkflow', + [stages.VerifyUser(must_see=True), + stages.Authorship(must_see=True), + stages.License(must_see=True), + stages.Policy(must_see=True), + stages.FileUpload(must_see=True), + stages.Process(must_see=True), + stages.Metadata(must_see=True), + stages.OptionalMetadata(required=False, must_see=True), + stages.FinalPreview(must_see=True) + ], + stages.Confirm() +) +"""Workflow for replacements.""" diff --git a/submit/workflow/conditions.py b/submit/workflow/conditions.py new file mode 100644 index 0000000..159a85c --- /dev/null +++ b/submit/workflow/conditions.py @@ -0,0 +1,72 @@ +from arxiv.submission.domain import Submission, SubmissionContent + + +def is_contact_verified(submission: Submission) -> bool: + """Determine whether the submitter has verified their information.""" + return submission.submitter_contact_verified is True + + +def is_authorship_indicated(submission: Submission) -> bool: + """Determine whether the submitter has indicated authorship.""" + return submission.submitter_is_author is not None + + +def has_license(submission: Submission) -> bool: + """Determine whether the submitter has selected a license.""" + return submission.license is not None + + +def is_policy_accepted(submission: Submission) -> bool: + """Determine whether the submitter has accepted arXiv policies.""" + return submission.submitter_accepts_policy is True + + +def has_primary(submission: Submission) -> bool: + """Determine whether the submitter selected a primary category.""" + return submission.primary_classification is not None + + +def has_secondary(submission: Submission) -> bool: + return len(submission.secondary_classification) > 0 + + +def has_valid_content(submission: Submission) -> bool: + """Determine whether the submitter has uploaded files.""" + return submission.source_content is not None and\ + submission.source_content.checksum is not None and\ + submission.source_content.source_format is not None and \ + submission.source_content.uncompressed_size > 0 and \ + submission.source_content.source_format != SubmissionContent.Format.INVALID + +def has_non_processing_content(submission: Submission) -> bool: + return (submission.source_content is not None and + submission.source_content.source_format is not None and + (submission.source_content.source_format != SubmissionContent.Format.TEX + and + submission.source_content.source_format != SubmissionContent.Format.POSTSCRIPT)) + +def is_source_processed(submission: Submission) -> bool: + """Determine whether the submitter has compiled their upload.""" + return has_valid_content(submission) and \ + (submission.is_source_processed or has_non_processing_content(submission)) + + +def is_metadata_complete(submission: Submission) -> bool: + """Determine whether the submitter has entered required metadata.""" + return (submission.metadata.title is not None + and submission.metadata.abstract is not None + and submission.metadata.authors_display is not None) + + +def is_opt_metadata_complete(submission: Submission) -> bool: + """Determine whether the user has set optional metadata fields.""" + return (submission.metadata.doi is not None + or submission.metadata.msc_class is not None + or submission.metadata.acm_class is not None + or submission.metadata.report_num is not None + or submission.metadata.journal_ref is not None) + + +def is_finalized(submission: Submission) -> bool: + """Determine whether the ui-app is finalized.""" + return bool(submission.is_finalized) diff --git a/submit/workflow/processor.py b/submit/workflow/processor.py new file mode 100644 index 0000000..9c621e9 --- /dev/null +++ b/submit/workflow/processor.py @@ -0,0 +1,81 @@ +"""Defines ui-app stages and workflows supported by this UI.""" + +from typing import Optional, Dict + +from arxiv.base import logging +from arxiv.submission.domain import Submission +from dataclasses import field, dataclass +from . import WorkflowDefinition, Stage + + +logger = logging.getLogger(__file__) + + +@dataclass +class WorkflowProcessor: + """Class to handle a ui-app moving through a WorkflowDefinition. + + The seen methods is_seen and mark_seen are handled with a Dict. This class + doesn't handle loading or saving that data. + """ + workflow: WorkflowDefinition + submission: Submission + seen: Dict[str, bool] = field(default_factory=dict) + + def is_complete(self) -> bool: + """Determine whether this workflow is complete.""" + return bool(self.submission.is_finalized) + + def next_stage(self, stage: Optional[Stage]) -> Optional[Stage]: + """Get the stage after the one in the parameter.""" + return self.workflow.next_stage(stage) + + def can_proceed_to(self, stage: Optional[Stage]) -> bool: + """Determine whether the user can proceed to a stage.""" + if stage is None: + return True + + must_be_done = self.workflow.order if stage == self.workflow.confirmation \ + else self.workflow.iter_prior(stage) + done = list([(stage, self.is_done(stage)) for stage in must_be_done]) + logger.debug(f'in can_proceed_to() done list is {done}') + return all(map(lambda x: x[1], done)) + + def current_stage(self) -> Optional[Stage]: + """Get the first stage in the workflow that is not done.""" + for stage in self.workflow.order: + if not self.is_done(stage): + return stage + return None + + def _seen_key(self, stage: Stage) -> str: + return f"{self.workflow.name}---" +\ + f"{stage.__class__.__name__}---{stage.label}---" + + def mark_seen(self, stage: Optional[Stage]) -> None: + """Mark a stage as seen by the user.""" + if stage is not None: + self.seen[self._seen_key(stage)] = True + + def is_seen(self, stage: Optional[Stage]) -> bool: + """Determine whether or not the user has seen this stage.""" + if stage is None: + return True + return self.seen.get(self._seen_key(stage), False) + + def is_done(self, stage: Optional[Stage]) -> bool: + """ + Evaluate whether a stage is sufficiently addressed for this workflow. + This considers whether the stage is complete (if required), and whether + the stage has been seen (if it must be seen). + """ + if stage is None: + return True + + return ((not stage.must_see or self.is_seen(stage)) + and + (not stage.required or stage.is_complete(self.submission))) + + def index(self, stage): + return self.workflow.index(stage) + diff --git a/submit/workflow/stages.py b/submit/workflow/stages.py new file mode 100644 index 0000000..1a274cf --- /dev/null +++ b/submit/workflow/stages.py @@ -0,0 +1,161 @@ +"""Workflow and stages related to new submissions""" + +from typing import Iterable, Optional, Callable, List, Iterator +from . import conditions +from arxiv.submission.domain import Submission + + +SubmissionCheck = Callable[[Submission], (bool)] +"""Function type that can be used to check if a ui-app meets + a condition.""" + + +class Stage: + """Class for workflow stages.""" + + endpoint: str + label: str + title: str + display: str + must_see: bool + required: bool + completed: List[SubmissionCheck] + + def __init__(self, required: bool = True, must_see: bool = False) -> None: + """ + Configure the stage for a particular workflow. + Parameters + ---------- + required : bool + This stage must be complete to proceed. + must_see : bool + This stage must be seen (even if already complete) to proceed. + """ + self.required = required + self.must_see = must_see + + def is_complete(self, submission: Submission) -> bool: + return all([fn(submission) for fn in self.completed]) + + +class VerifyUser(Stage): + """The user is asked to verify their personal information.""" + + endpoint = 'verify_user' + label = 'verify your personal information' + title = 'Verify user info' + display = 'Verify User' + completed = [conditions.is_contact_verified] + + +class Authorship(Stage): + """The user is asked to verify their authorship status.""" + + endpoint = 'authorship' + label = 'confirm authorship' + title = "Confirm authorship" + display = "Authorship" + completed = [conditions.is_authorship_indicated] + + +class License(Stage): + """The user is asked to select a license.""" + + endpoint = 'license' + label = 'choose a license' + title = "Choose license" + display = "License" + completed = [conditions.has_license] + + +class Policy(Stage): + """The user is required to agree to arXiv policies.""" + + endpoint = 'policy' + label = 'accept arXiv ui-app policies' + title = "Acknowledge policy" + display = "Policy" + completed = [conditions.is_policy_accepted] + + +class Classification(Stage): + """The user is asked to select a primary category.""" + + endpoint = 'classification' + label = 'select a primary category' + title = "Choose category" + display = "Category" + completed = [conditions.has_primary] + + +class CrossList(Stage): + """The user is given the option of selecting cross-list categories.""" + + endpoint = 'cross_list' + label = 'add cross-list categories' + title = "Add cross-list" + display = "Cross-list" + completed = [conditions.has_secondary] + + +class FileUpload(Stage): + """The user is asked to upload files for their ui-app.""" + + endpoint = 'file_upload' + label = 'upload your ui-app files' + title = "File upload" + display = "Upload Files" + always_check = True + completed = [conditions.has_valid_content] + + +class Process(Stage): + """Uploaded files are processed; this is primarily to compile LaTeX.""" + + endpoint = 'file_process' + label = 'process your ui-app files' + title = "File process" + display = "Process Files" + """We need to re-process every time the source is updated.""" + completed = [conditions.is_source_processed] + + +class Metadata(Stage): + """The user is asked to require core metadata fields, like title.""" + + endpoint = 'add_metadata' + label = 'add required metadata' + title = "Add metadata" + display = "Metadata" + completed = [conditions.is_metadata_complete] + + +class OptionalMetadata(Stage): + """The user is given the option of entering optional metadata.""" + + endpoint = 'add_optional_metadata' + label = 'add optional metadata' + title = "Add optional metadata" + display = "Opt. Metadata" + completed = [conditions.is_opt_metadata_complete] + + +class FinalPreview(Stage): + """The user is asked to review the ui-app before finalizing.""" + + endpoint = 'final_preview' + label = 'preview and approve your ui-app' + title = "Final preview" + display = "Preview" + completed = [conditions.is_finalized] + + +class Confirm(Stage): + """The ui-app is confirmed.""" + + endpoint = 'confirmation' + label = 'your ui-app is confirmed' + title = "Submission confirmed" + display = "Confirmed" + completed = [lambda _:False] + diff --git a/submit/workflow/test_new_submission.py b/submit/workflow/test_new_submission.py new file mode 100644 index 0000000..7fb893c --- /dev/null +++ b/submit/workflow/test_new_submission.py @@ -0,0 +1,258 @@ +"""Tests for workflow""" + +from unittest import TestCase, mock +from submit import workflow +from submit.workflow import processor +from arxiv.submission.domain.event import CreateSubmission +from arxiv.submission.domain.agent import User +from submit.workflow.stages import * +from arxiv.submission.domain.submission import SubmissionContent, SubmissionMetadata + + +class TestNewSubmissionWorkflow(TestCase): + + def testWorkflowGetitem(self): + wf = workflow.WorkflowDefinition( + name='TestingWorkflow', + order=[VerifyUser(), Policy(), FinalPreview()]) + + self.assertIsNotNone(wf[VerifyUser]) + self.assertEqual(wf[VerifyUser].__class__, VerifyUser) + self.assertEqual(wf[0].__class__, VerifyUser) + + self.assertEqual(wf[VerifyUser], wf[0]) + self.assertEqual(wf[VerifyUser], wf['VerifyUser']) + self.assertEqual(wf[VerifyUser], wf['verify_user']) + self.assertEqual(wf[VerifyUser], wf[wf.order[0]]) + + self.assertEqual(next(wf.iter_prior(wf[Policy])), wf[VerifyUser]) + + def testVerifyUser(self): + seen = {} + submitter = User('Bob', 'FakePants', 'Sponge', + 'bob_id_xy21', 'cornell.edu', 'UNIT_TEST_AGENT') + cevnt = CreateSubmission(creator=submitter, client=submitter) + submission = cevnt.apply(None) + + nswfps = processor.WorkflowProcessor(workflow.SubmissionWorkflow, + submission, seen) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[VerifyUser]) + + submission.submitter_contact_verified = True + nswfps.mark_seen(nswfps.workflow[VerifyUser]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[Authorship]) + + submission.submitter_is_author = True + nswfps.mark_seen(nswfps.workflow[Authorship]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[License]) + + submission.license = "someLicense" + nswfps.mark_seen(nswfps.workflow[License]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[Policy]) + + submission.submitter_accepts_policy = True + nswfps.mark_seen(nswfps.workflow[Policy]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), + nswfps.workflow[Classification]) + + submission.primary_classification = {'category': "FakePrimaryCategory"} + nswfps.mark_seen(nswfps.workflow[Classification]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[CrossList]) + + submission.secondary_classification = [ + {'category': 'fakeSecondaryCategory'}] + nswfps.mark_seen(nswfps.workflow[CrossList]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[FileUpload]) + + submission.source_content = SubmissionContent( + 'identifierX', 'checksum_xyz', 100, 10, SubmissionContent.Format.TEX) + nswfps.mark_seen(nswfps.workflow[FileUpload]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertFalse(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[Process]) + + #Now try a PDF upload + submission.source_content = SubmissionContent( + 'identifierX', 'checksum_xyz', 100, 10, SubmissionContent.Format.PDF) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + + self.assertFalse(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + self.assertEqual(nswfps.current_stage(), nswfps.workflow[Metadata]) + + submission.metadata = SubmissionMetadata(title="FakeOFakeyDuFakeFake", + abstract="I like it.", + authors_display="Bob Fakeyfake") + nswfps.mark_seen(nswfps.workflow[Metadata]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + self.assertEqual(nswfps.current_stage(), nswfps.workflow[OptionalMetadata]) + + #optional metadata only seen + nswfps.mark_seen(nswfps.workflow[OptionalMetadata]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + + self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) + + submission.status = 'submitted' + nswfps.mark_seen(nswfps.workflow[FinalPreview]) + + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[Classification])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) + self.assertTrue(nswfps.can_proceed_to( + nswfps.workflow[OptionalMetadata])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) + self.assertTrue(nswfps.can_proceed_to(nswfps.workflow.confirmation)) diff --git a/src/arxiv/submission/tests/schedule/__init__.py b/submit_ce/__init__.py similarity index 100% rename from src/arxiv/submission/tests/schedule/__init__.py rename to submit_ce/__init__.py diff --git a/src/arxiv/submission/tests/serializer/__init__.py b/submit_ce/submit_fastapi/__init__.py similarity index 100% rename from src/arxiv/submission/tests/serializer/__init__.py rename to submit_ce/submit_fastapi/__init__.py diff --git a/src/arxiv/submit_fastapi/__init__.py b/submit_ce/submit_fastapi/api/__init__.py similarity index 100% rename from src/arxiv/submit_fastapi/__init__.py rename to submit_ce/submit_fastapi/api/__init__.py diff --git a/src/arxiv/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py similarity index 66% rename from src/arxiv/submit_fastapi/api/default_api.py rename to submit_ce/submit_fastapi/api/default_api.py index d3977cd..3cd8b36 100644 --- a/src/arxiv/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -1,6 +1,6 @@ # coding: utf-8 -from typing import Dict, List # noqa: F401 +from typing import Dict, List, Callable # noqa: F401 from fastapi import ( # noqa: F401 APIRouter, @@ -17,56 +17,57 @@ status, ) -from arxiv.submit_fastapi.config import config -from arxiv.submit_fastapi.api.models.extra_models import TokenModel # noqa: F401 -from arxiv.submit_fastapi.api.models.agreement import Agreement +from submit_ce.submit_fastapi.config import config -implementation = config.submission_api_implementation() -impl_depends = config.submission_api_implementation_depends_function +from .default_api_base import BaseDefaultApi +from .models.agreement import Agreement +from ..implementations import ImplementationConfig + +if not isinstance(config.submission_api_implementation, ImplementationConfig): + raise ValueError("submission_api_implementation must be of class ImplementationConfig.") + +implementation: BaseDefaultApi = config.submission_api_implementation.impl +"""Implementation to use for the API.""" + +impl_depends: Callable = config.submission_api_implementation.depends_fn +"""A depends the implementation depends on.""" router = APIRouter() -@router.get( - "/status", + +@router.post( + "/", responses={ - 200: {"description": "system is working correctly"}, - 500: {"description": "system is not working correctly"}, + 200: {"model": str, "description": "Successfully started a submission."}, }, - tags=["default"], + tags=["submit"], response_model_by_alias=True, ) -async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: - """Get information about the current status of file management service.""" - return await implementation.get_service_status(impl_dep) +async def start( +) -> str: + """Start a submission and get a submission ID. + TODO Maybe the start needs to include accepting an agreement? -# @router.get( -# "/{submission_id}", -# responses={ -# 200: {"model": object, "description": "The submission data."}, -# }, -# tags=["default"], -# response_model_by_alias=True, -# ) -# async def get_submission( -# submission_id: str = Path(..., description="Id of the submission to get."), -# ) -> object: -# """Get information about a submission.""" -# return await implementation.get_submission(submission_id) - - -# @router.post( -# "/", -# responses={ -# 200: {"model": str, "description": "Successfully started a new submission."}, -# }, -# tags=["default"], -# response_model_by_alias=True, -# ) -# async def new( -# ) -> str: -# """Start a submission and get a submission ID.""" -# return await implementation.new() + TODO parameters for new,replacement,withdraw,cross,jref + + TODO How to better indicate that the body is a string that is the submission id? Links?""" + return await implementation.start() + + +@router.get( + "/{submission_id}", + responses={ + 200: {"model": object, "description": "The submission data."}, + }, + tags=["submit"], + response_model_by_alias=True, +) +async def get_submission( + submission_id: str = Path(..., description="Id of the submission to get."), +) -> object: + """Get information about a submission.""" + return await implementation.get_submission(submission_id) @router.post( @@ -78,7 +79,7 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: 403: {"description": "Forbidden. Client or user is not authorized to upload. The agreement was not accepted."}, 500: {"description": "Error. There was a problem. The agreement was not accepted."}, }, - tags=["default"], + tags=["submit"], response_model_by_alias=True, ) async def submission_id_accept_policy_post( @@ -91,11 +92,11 @@ async def submission_id_accept_policy_post( @router.post( - "/{submission_id}/Deposited", + "/{submission_id}/deposited", responses={ 200: {"description": "Deposited has been recorded."}, }, - tags=["default"], + tags=["postsubmit"], response_model_by_alias=True, ) async def submission_id_deposited_post( @@ -111,7 +112,7 @@ async def submission_id_deposited_post( responses={ 200: {"description": "The submission has been marked as in procesing for deposit."}, }, - tags=["default"], + tags=["postsubmit"], response_model_by_alias=True, ) async def submission_id_mark_processing_for_deposit_post( @@ -127,7 +128,7 @@ async def submission_id_mark_processing_for_deposit_post( responses={ 200: {"description": "The submission has been marked as no longer in procesing for deposit."}, }, - tags=["default"], + tags=["postsubmit"], response_model_by_alias=True, ) async def submission_id_unmark_processing_for_deposit_post( @@ -136,3 +137,17 @@ async def submission_id_unmark_processing_for_deposit_post( ) -> None: """Indicate that an external system in no longer working on depositing this submission. This does not indicate that is was successfully deposited. """ return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, submission_id) + + +@router.get( + "/status", + responses={ + 200: {"description": "system is working correctly"}, + 500: {"description": "system is not working correctly"}, + }, + tags=["service"], + response_model_by_alias=True, +) +async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: + """Get information about the current status of file management service.""" + return await implementation.get_service_status(impl_dep) diff --git a/src/arxiv/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py similarity index 98% rename from src/arxiv/submit_fastapi/api/default_api_base.py rename to submit_ce/submit_fastapi/api/default_api_base.py index 11b5652..477b9a5 100644 --- a/src/arxiv/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -18,7 +18,7 @@ async def get_submission( ... @abstractmethod - async def new( + async def begin( self, impl_data: Dict, diff --git a/src/arxiv/submit_fastapi/api/__init__.py b/submit_ce/submit_fastapi/api/models/__init__.py similarity index 100% rename from src/arxiv/submit_fastapi/api/__init__.py rename to submit_ce/submit_fastapi/api/models/__init__.py diff --git a/src/arxiv/submit_fastapi/api/models/agreement.py b/submit_ce/submit_fastapi/api/models/agreement.py similarity index 94% rename from src/arxiv/submit_fastapi/api/models/agreement.py rename to submit_ce/submit_fastapi/api/models/agreement.py index 5c3e30f..40411a3 100644 --- a/src/arxiv/submit_fastapi/api/models/agreement.py +++ b/submit_ce/submit_fastapi/api/models/agreement.py @@ -21,8 +21,11 @@ -from pydantic import BaseModel, ConfigDict, StrictStr +from pydantic import BaseModel, StrictStr from typing import Any, ClassVar, Dict, List + +from .event_info import EventInfo + try: from typing import Self except ImportError: @@ -33,8 +36,8 @@ class Agreement(BaseModel): The sender of this request agrees to the statement in the agreement """ # noqa: E501 submission_id: StrictStr - name: StrictStr - agreement: StrictStr + accepted_policy: StrictStr + event_info: EventInfo __properties: ClassVar[List[str]] = ["submission_id", "name", "agreement"] model_config = { diff --git a/submit_ce/submit_fastapi/api/models/event_info.py b/submit_ce/submit_fastapi/api/models/event_info.py new file mode 100644 index 0000000..356f9a0 --- /dev/null +++ b/submit_ce/submit_fastapi/api/models/event_info.py @@ -0,0 +1,21 @@ +# coding: utf-8 + +""" + event info + + Basic information about an event. +""" # noqa: E501 + + +from __future__ import annotations +import re +from datetime import datetime + +from pydantic import BaseModel + + +class EventInfo(BaseModel): + event_id: str + recorded: datetime + submission_id: str + user_id: str \ No newline at end of file diff --git a/src/arxiv/submit_fastapi/api/models/extra_models.py b/submit_ce/submit_fastapi/api/models/extra_models.py similarity index 100% rename from src/arxiv/submit_fastapi/api/models/extra_models.py rename to submit_ce/submit_fastapi/api/models/extra_models.py diff --git a/src/arxiv/submit_fastapi/app.py b/submit_ce/submit_fastapi/app.py similarity index 56% rename from src/arxiv/submit_fastapi/app.py rename to submit_ce/submit_fastapi/app.py index c101fc5..1b1af33 100644 --- a/src/arxiv/submit_fastapi/app.py +++ b/submit_ce/submit_fastapi/app.py @@ -1,10 +1,16 @@ from fastapi import FastAPI -from arxiv.submit_fastapi.api.default_api import router as DefaultApiRouter +from submit_ce.submit_fastapi.api.default_api import router as DefaultApiRouter from .config import config +from .implementations import legacy_implementation + app = FastAPI( title="arxiv submit", description="No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)", version="0.1", ) app.state.config = config + +config.submission_api_implementation.setup_fn(config) +legacy_implementation.legacy_bootstrap(config) + app.include_router(DefaultApiRouter) diff --git a/submit_ce/submit_fastapi/config.py b/submit_ce/submit_fastapi/config.py new file mode 100644 index 0000000..30c6a87 --- /dev/null +++ b/submit_ce/submit_fastapi/config.py @@ -0,0 +1,22 @@ +import secrets + +from pydantic_settings import BaseSettings + +from pydantic import SecretStr, ImportString + + +class Settings(BaseSettings): + classic_db_uri: str = 'sqlite://legacy.db' + """arXiv legacy DB URL.""" + + jwt_secret: SecretStr = "not-set-" + secrets.token_urlsafe(16) + """NG JWT_SECRET from arxiv-auth login service""" + + submission_api_implementation: ImportString = 'submit_ce.submit_fastapi.implementations.legacy_implementation.implementation' + """Class to use for submission API implementation.""" + + +config = Settings(_case_sensitive=False) +"""Settings build from defaults, env file, and env vars. + +Environment vars have the highest precedence, defaults the lowest.""" diff --git a/src/arxiv/submit_fastapi/db.py b/submit_ce/submit_fastapi/db.py similarity index 93% rename from src/arxiv/submit_fastapi/db.py rename to submit_ce/submit_fastapi/db.py index 1ae4717..4f8bc63 100644 --- a/src/arxiv/submit_fastapi/db.py +++ b/submit_ce/submit_fastapi/db.py @@ -24,7 +24,7 @@ def get_db(session_local=Depends(get_sessionlocal)): with session_local() as session: try: yield session - if session.new or session.dirty or session.deleted: + if session.begin or session.dirty or session.deleted: session.commit() except Exception: session.rollback() diff --git a/submit_ce/submit_fastapi/implementations/__init__.py b/submit_ce/submit_fastapi/implementations/__init__.py new file mode 100644 index 0000000..ea2acae --- /dev/null +++ b/submit_ce/submit_fastapi/implementations/__init__.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import Callable + +from pydantic_settings import BaseSettings + +from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi + + +@dataclass +class ImplementationConfig: + impl: BaseDefaultApi + depends_fn: Callable + setup_fn: Callable[[BaseSettings], None] diff --git a/src/arxiv/submit_fastapi/api/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py similarity index 55% rename from src/arxiv/submit_fastapi/api/legacy_implementation.py rename to submit_ce/submit_fastapi/implementations/legacy_implementation.py index 2480423..1518a5d 100644 --- a/src/arxiv/submit_fastapi/api/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -2,11 +2,13 @@ from fastapi import Depends -from .default_api_base import BaseDefaultApi +from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi import logging -from .models.agreement import Agreement -from ..db import get_db +from submit_ce.submit_fastapi.api.models.agreement import Agreement +from submit_ce.submit_fastapi.config import Settings +from submit_ce.submit_fastapi.db import get_db, get_sessionlocal +from submit_ce.submit_fastapi.implementations import ImplementationConfig logger = logging.getLogger(__name__) @@ -20,7 +22,7 @@ class LegacySubmitImplementation(BaseDefaultApi): async def get_submission(self, impl_data: Dict, submission_id: str) -> object: pass - async def new(self, impl_data: Dict) -> str: + async def begin(self, impl_data: Dict) -> str: pass async def submission_id_accept_policy_post(self, impl_data: Dict, submission_id: str, @@ -37,4 +39,21 @@ async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict pass async def get_service_status(self, impl_data: dict): - return f"{self.__class__.__name__} impl_data: {impl_data}" \ No newline at end of file + return f"{self.__class__.__name__} impl_data: {impl_data}" + + +def setup(settings: Settings) -> None: + pass + +def legacy_bootstrap(settings: Settings) -> None: + sessionlocal = get_sessionlocal() + with sessionlocal() as session: + import arxiv.db.models as models + models.configure_db_engine(session.get_bind()) + session.create_all() + +implementation = ImplementationConfig( + impl=LegacySubmitImplementation(), + depends_fn=legacy_depends, + setup_fn=setup, +) \ No newline at end of file diff --git a/src/arxiv/submit_fastapi/main.py b/submit_ce/submit_fastapi/main.py similarity index 100% rename from src/arxiv/submit_fastapi/main.py rename to submit_ce/submit_fastapi/main.py diff --git a/tests/conftest.py b/tests/conftest.py index 33361c4..b5a5040 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from arxiv.submit_fastapi import app as application +from submit_ce.submit_fastapi import app as application @pytest.fixture diff --git a/tests/test_default_api.py b/tests/test_default_api.py index ac896c5..78bac3b 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient -from arxiv.submit_fastapi.api.models.agreement import Agreement # noqa: F401 +from submit_ce.submit_fastapi.api.models.agreement import Agreement # noqa: F401 def test_get_service_status(client: TestClient): From 5b0b58867bba4d33f34a53af3a8c1d08db040035 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 17 Sep 2024 12:55:22 -0400 Subject: [PATCH 08/28] Stub tests create legacy sqlite db --- submit_ce/submit_fastapi/app.py | 1 - submit_ce/submit_fastapi/config.py | 5 +-- .../implementations/legacy_implementation.py | 8 +---- tests/conftest.py | 36 +++++++++++++++++-- tests/test_default_api.py | 5 +-- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/submit_ce/submit_fastapi/app.py b/submit_ce/submit_fastapi/app.py index 1b1af33..6cca31e 100644 --- a/submit_ce/submit_fastapi/app.py +++ b/submit_ce/submit_fastapi/app.py @@ -11,6 +11,5 @@ app.state.config = config config.submission_api_implementation.setup_fn(config) -legacy_implementation.legacy_bootstrap(config) app.include_router(DefaultApiRouter) diff --git a/submit_ce/submit_fastapi/config.py b/submit_ce/submit_fastapi/config.py index 30c6a87..efd5f06 100644 --- a/submit_ce/submit_fastapi/config.py +++ b/submit_ce/submit_fastapi/config.py @@ -6,11 +6,8 @@ class Settings(BaseSettings): - classic_db_uri: str = 'sqlite://legacy.db' - """arXiv legacy DB URL.""" + """CLASSIC_DB_URI and other configs are from arxiv-base arxiv.config.""" - jwt_secret: SecretStr = "not-set-" + secrets.token_urlsafe(16) - """NG JWT_SECRET from arxiv-auth login service""" submission_api_implementation: ImportString = 'submit_ce.submit_fastapi.implementations.legacy_implementation.implementation' """Class to use for submission API implementation.""" diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 1518a5d..cb96d0e 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -7,7 +7,7 @@ from submit_ce.submit_fastapi.api.models.agreement import Agreement from submit_ce.submit_fastapi.config import Settings -from submit_ce.submit_fastapi.db import get_db, get_sessionlocal +from submit_ce.submit_fastapi.db import get_db from submit_ce.submit_fastapi.implementations import ImplementationConfig logger = logging.getLogger(__name__) @@ -45,12 +45,6 @@ async def get_service_status(self, impl_data: dict): def setup(settings: Settings) -> None: pass -def legacy_bootstrap(settings: Settings) -> None: - sessionlocal = get_sessionlocal() - with sessionlocal() as session: - import arxiv.db.models as models - models.configure_db_engine(session.get_bind()) - session.create_all() implementation = ImplementationConfig( impl=LegacySubmitImplementation(), diff --git a/tests/conftest.py b/tests/conftest.py index b5a5040..c1cbd94 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,44 @@ +import shutil +import tempfile + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + + -from submit_ce.submit_fastapi import app as application +@pytest.fixture(scope='session') +def test_dir(): + db_path = tempfile.mkdtemp() + yield db_path + shutil.rmtree(db_path) +@pytest.fixture(scope='session') +def classic_db(test_dir, echo: bool=False) -> None: + """Temp classic db with all tables created but no data.""" + url = f"sqlite:///{test_dir}/test_classic.db" + engine = create_engine(url, echo=echo) + from arxiv.db.models import configure_db_engine + configure_db_engine(engine, None) + from arxiv.db import metadata + with Session(engine) as session: + import arxiv.db.models as models + models.configure_db_engine(session.get_bind(), None) + metadata.create_all(bind=engine) + session.commit() + + yield (engine, url, test_dir) @pytest.fixture -def app() -> FastAPI: +def app(test_dir, classic_db) -> FastAPI: + engine, url, test_dir = classic_db + from arxiv.config import settings + settings.CLASSIC_DB_URI = url + + # Don't import until now so settings can be altered + from submit_ce.submit_fastapi import app as application application.dependency_overrides = {} return application diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 78bac3b..1476437 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -7,10 +7,7 @@ def test_get_service_status(client: TestClient): - """Test case for get_service_status - - - """ + """Test case for get_service_status""" headers = { } From 158116e619f1216f26aa8cb9972df1b55e7adaec Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 17 Sep 2024 12:56:26 -0400 Subject: [PATCH 09/28] gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 82f9275..86cd75e 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# morbund NG arxiv code +graveyard/ \ No newline at end of file From fbd6294549ff611248418be971f561c764c2be0f Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 17 Sep 2024 13:58:01 -0400 Subject: [PATCH 10/28] Changes to get conftest working --- main.py | 5 +++ submit_ce/submit_fastapi/api/default_api.py | 34 +++++++++-------- submit_ce/submit_fastapi/app.py | 2 - submit_ce/submit_fastapi/db.py | 32 ---------------- .../implementations/legacy_implementation.py | 37 +++++++++++++++++-- tests/conftest.py | 3 +- 6 files changed, 57 insertions(+), 56 deletions(-) create mode 100644 main.py delete mode 100644 submit_ce/submit_fastapi/db.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..266b8c5 --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +if __name__ == "__main__": + import uvicorn + uvicorn.run("submit_ce.submit_fastapi.app:app", host="127.0.0.1", port=8000, reload=True) + + diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index 3cd8b36..bcc1c58 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -34,6 +34,20 @@ router = APIRouter() +@router.get( + "/status", + responses={ + 200: {"description": "system is working correctly"}, + 500: {"description": "system is not working correctly"}, + }, + tags=["service"], + response_model_by_alias=True, +) +async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: + """Get information about the current status of file management service.""" + print("Here in default_api get_service_status") + return await implementation.get_service_status(impl_dep) + @router.post( "/", @@ -43,8 +57,7 @@ tags=["submit"], response_model_by_alias=True, ) -async def start( -) -> str: +async def start(impl_dep = Depends(impl_depends)) -> str: """Start a submission and get a submission ID. TODO Maybe the start needs to include accepting an agreement? @@ -52,7 +65,7 @@ async def start( TODO parameters for new,replacement,withdraw,cross,jref TODO How to better indicate that the body is a string that is the submission id? Links?""" - return await implementation.start() + return await implementation.start(impl_dep) @router.get( @@ -65,9 +78,10 @@ async def start( ) async def get_submission( submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep = Depends(impl_depends) ) -> object: """Get information about a submission.""" - return await implementation.get_submission(submission_id) + return await implementation.get_submission(impl_dep, submission_id) @router.post( @@ -139,15 +153,3 @@ async def submission_id_unmark_processing_for_deposit_post( return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, submission_id) -@router.get( - "/status", - responses={ - 200: {"description": "system is working correctly"}, - 500: {"description": "system is not working correctly"}, - }, - tags=["service"], - response_model_by_alias=True, -) -async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: - """Get information about the current status of file management service.""" - return await implementation.get_service_status(impl_dep) diff --git a/submit_ce/submit_fastapi/app.py b/submit_ce/submit_fastapi/app.py index 6cca31e..5b98285 100644 --- a/submit_ce/submit_fastapi/app.py +++ b/submit_ce/submit_fastapi/app.py @@ -1,7 +1,6 @@ from fastapi import FastAPI from submit_ce.submit_fastapi.api.default_api import router as DefaultApiRouter from .config import config -from .implementations import legacy_implementation app = FastAPI( title="arxiv submit", @@ -11,5 +10,4 @@ app.state.config = config config.submission_api_implementation.setup_fn(config) - app.include_router(DefaultApiRouter) diff --git a/submit_ce/submit_fastapi/db.py b/submit_ce/submit_fastapi/db.py deleted file mode 100644 index 4f8bc63..0000000 --- a/submit_ce/submit_fastapi/db.py +++ /dev/null @@ -1,32 +0,0 @@ -from fastapi import Depends -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -_sessionLocal = sessionmaker(autocommit=False, autoflush=False) - - -def get_sessionlocal(): - global _sessionLocal - if _sessionLocal is None: - from .config import config - if 'sqlite' in config.classic_db_uri: - args = {"check_same_thread": False} - else: - args = {} - engine = create_engine(config.classic_db_uri, echo=config.echo_sql, connect_args=args) - _sessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - return _sessionLocal - - -def get_db(session_local=Depends(get_sessionlocal)): - """Dependency for fastapi routes""" - with session_local() as session: - try: - yield session - if session.begin or session.dirty or session.deleted: - session.commit() - except Exception: - session.rollback() - raise - diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index cb96d0e..4209388 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -1,17 +1,44 @@ +import logging from typing import Dict +from arxiv.config import settings from fastapi import Depends +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi -import logging - from submit_ce.submit_fastapi.api.models.agreement import Agreement from submit_ce.submit_fastapi.config import Settings -from submit_ce.submit_fastapi.db import get_db from submit_ce.submit_fastapi.implementations import ImplementationConfig logger = logging.getLogger(__name__) +_sessionLocal = sessionmaker(autocommit=False, autoflush=False) + + +def get_sessionlocal(): + global _sessionLocal + if _sessionLocal is None: + if 'sqlite' in settings.CLASSIC_DB_URI: + args = {"check_same_thread": False} + else: + args = {} + engine = create_engine(settings.CLASSIC_DB_URI, echo=settings.ECHO_SQL, connect_args=args) + _sessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + return _sessionLocal + +def get_db(session_local=Depends(get_sessionlocal)): + """Dependency for fastapi routes""" + with session_local() as session: + try: + yield session + if session.begin or session.dirty or session.deleted: + session.commit() + except Exception: + session.rollback() + raise + def legacy_depends(db=Depends(get_db)) -> dict: return {"db": db} @@ -39,6 +66,7 @@ async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict pass async def get_service_status(self, impl_data: dict): + logger.info("Here in get_service_status") return f"{self.__class__.__name__} impl_data: {impl_data}" @@ -50,4 +78,5 @@ def setup(settings: Settings) -> None: impl=LegacySubmitImplementation(), depends_fn=legacy_depends, setup_fn=setup, -) \ No newline at end of file +) + diff --git a/tests/conftest.py b/tests/conftest.py index c1cbd94..12e5653 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,9 +38,8 @@ def app(test_dir, classic_db) -> FastAPI: settings.CLASSIC_DB_URI = url # Don't import until now so settings can be altered - from submit_ce.submit_fastapi import app as application + from submit_ce.submit_fastapi.app import app as application application.dependency_overrides = {} - return application From b39fb1114dd49916286616c3aaf2af9d52e1cddf Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 17 Sep 2024 14:55:01 -0400 Subject: [PATCH 11/28] Adding User/Agent to api --- submit_ce/domain/agent.py | 87 +++++++++++++++++++ submit_ce/submit_fastapi/api/default_api.py | 59 +++++++------ .../submit_fastapi/api/default_api_base.py | 52 +++++++---- submit_ce/submit_fastapi/auth.py | 11 +++ .../implementations/legacy_implementation.py | 17 ++-- tests/test_default_api.py | 41 +++------ 6 files changed, 185 insertions(+), 82 deletions(-) create mode 100644 submit_ce/domain/agent.py create mode 100644 submit_ce/submit_fastapi/auth.py diff --git a/submit_ce/domain/agent.py b/submit_ce/domain/agent.py new file mode 100644 index 0000000..f60545c --- /dev/null +++ b/submit_ce/domain/agent.py @@ -0,0 +1,87 @@ +"""Data structures for agents.""" + +from typing import Any, Optional, List, Union, Type, Dict + +from dataclasses import dataclass, field + +from pydantic import BaseModel, Field + + +class Agent(BaseModel): + """ + Base class for actors that are responsible for events. + """ + + native_id: str + """Type-specific identifier for the agent. This might be an URI.""" + + hostname: Optional[str] = Field(default=None) + """Hostname or IP address from which user requests are originating.""" + + name: str + username: str + email: str + endorsements: List[str] = Field(default_factory=list) + + @classmethod + def get_agent_type(cls) -> str: + """Get the name of the instance's class.""" + return cls.__name__ + + def __eq__(self, other: Any) -> bool: + """Equality comparison for agents based on type and identifier.""" + # if not isinstance(other, self.__class__): + # return False + return self.native_id == other.native_id + + +class User(Agent): + """A human end user.""" + + forename: str = field(default_factory=str) + surname: str = field(default_factory=str) + suffix: str = field(default_factory=str) + identifier: Optional[str] = field(default=None) + affiliation: str = field(default_factory=str) + + def get_name(self) -> str: + """Full name of the user.""" + return f"{self.forename} {self.surname} {self.suffix}" + + +class System(Agent): + """The submission application.""" + pass + + +@dataclass +class Client(Agent): + """A non-human third party, usually an API client.""" + pass + + +_agent_types: Dict[str, Type[Agent]] = { + User.get_agent_type(): User, + System.get_agent_type(): System, + Client.get_agent_type(): Client, +} + + +def agent_factory(**data: Union[Agent, dict]) -> Agent: + """Instantiate a subclass of :class:`.Agent`.""" + if isinstance(data, Agent): + return data + agent_type = str(data.pop('agent_type')) + native_id = data.pop('native_id') + if not agent_type or not native_id: + raise ValueError('No such agent: %s, %s' % (agent_type, native_id)) + if agent_type not in _agent_types: + raise ValueError(f'No such agent type: {agent_type}') + + # Mypy chokes on meta-stuff like this. One of the goals of this factory + # function is to not have to write code for each agent subclass. We can + # revisit this in the future. For now, this code is correct, it just isn't + # easy to type-check. + klass = _agent_types[agent_type] + data = {k: v for k, v in data.items() if k in klass.__dataclass_fields__} + return klass(native_id, **data) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index bcc1c58..b9616cc 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -1,6 +1,6 @@ # coding: utf-8 -from typing import Dict, List, Callable # noqa: F401 +from typing import Dict, List, Callable, Annotated # noqa: F401 from fastapi import ( # noqa: F401 APIRouter, @@ -21,7 +21,9 @@ from .default_api_base import BaseDefaultApi from .models.agreement import Agreement +from ..auth import get_user from ..implementations import ImplementationConfig +from ...domain.agent import Agent if not isinstance(config.submission_api_implementation, ImplementationConfig): raise ValueError("submission_api_implementation must be of class ImplementationConfig.") @@ -32,8 +34,11 @@ impl_depends: Callable = config.submission_api_implementation.depends_fn """A depends the implementation depends on.""" +userDep = Annotated[Agent, Depends(get_user)] + router = APIRouter() + @router.get( "/status", responses={ @@ -57,7 +62,7 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: tags=["submit"], response_model_by_alias=True, ) -async def start(impl_dep = Depends(impl_depends)) -> str: +async def begin(impl_dep=Depends(impl_depends), user=userDep) -> str: """Start a submission and get a submission ID. TODO Maybe the start needs to include accepting an agreement? @@ -65,7 +70,7 @@ async def start(impl_dep = Depends(impl_depends)) -> str: TODO parameters for new,replacement,withdraw,cross,jref TODO How to better indicate that the body is a string that is the submission id? Links?""" - return await implementation.start(impl_dep) + return await implementation.begin(impl_dep, user) @router.get( @@ -77,11 +82,11 @@ async def start(impl_dep = Depends(impl_depends)) -> str: response_model_by_alias=True, ) async def get_submission( - submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep = Depends(impl_depends) + submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep=Depends(impl_depends), user=userDep ) -> object: """Get information about a submission.""" - return await implementation.get_submission(impl_dep, submission_id) + return await implementation.get_submission(impl_dep, user, submission_id) @router.post( @@ -97,12 +102,13 @@ async def get_submission( response_model_by_alias=True, ) async def submission_id_accept_policy_post( - submission_id: str = Path(..., description="Id of the submission to get."), - agreement: Agreement = Body(None, description=""), - impl_dep: dict = Depends(impl_depends), + submission_id: str = Path(..., description="Id of the submission to get."), + agreement: Agreement = Body(None, description=""), + impl_dep: dict = Depends(impl_depends), + user=userDep ) -> object: """Agree to an arXiv policy to initiate a new item submission or a change to an existing item. """ - return await implementation.submission_id_accept_policy_post(impl_dep, submission_id, agreement) + return await implementation.submission_id_accept_policy_post(impl_dep, user, submission_id, agreement) @router.post( @@ -110,46 +116,47 @@ async def submission_id_accept_policy_post( responses={ 200: {"description": "Deposited has been recorded."}, }, - tags=["postsubmit"], + tags=["post submit"], response_model_by_alias=True, ) async def submission_id_deposited_post( - submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), + submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), user=userDep ) -> None: """The submission has been successfully deposited by an external service.""" - return await implementation.submission_id_deposited_post(impl_dep, submission_id) + return await implementation.submission_id_deposited_post(impl_dep, user, submission_id) @router.post( "/{submission_id}/markProcessingForDeposit", responses={ - 200: {"description": "The submission has been marked as in procesing for deposit."}, + 200: {"description": "The submission has been marked as in processing for deposit."}, }, - tags=["postsubmit"], + tags=["post submit"], response_model_by_alias=True, ) async def submission_id_mark_processing_for_deposit_post( - submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), + submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), user=userDep ) -> None: """Mark that the submission is being processed for deposit.""" - return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, submission_id) + return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, user, submission_id) @router.post( "/{submission_id}/unmarkProcessingForDeposit", responses={ - 200: {"description": "The submission has been marked as no longer in procesing for deposit."}, + 200: {"description": "The submission has been marked as no longer in processing for deposit."}, }, - tags=["postsubmit"], + tags=["post submit"], response_model_by_alias=True, ) async def submission_id_unmark_processing_for_deposit_post( - submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), + submission_id: str = Path(..., description="Id of the submission to get."), + impl_dep: dict = Depends(impl_depends), user=userDep ) -> None: - """Indicate that an external system in no longer working on depositing this submission. This does not indicate that is was successfully deposited. """ - return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, submission_id) - + """Indicate that an external system in no longer working on depositing this submission. + This just indicates that the submission is no longer in processing state. This does not indicate that it + was successfully deposited. """ + return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, user, submission_id) diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py index 477b9a5..c4e6b81 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -4,61 +4,75 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 from .models.agreement import Agreement +from ...domain.agent import Agent class BaseDefaultApi(ABC): @abstractmethod async def get_submission( - self, - impl_data: Dict, - submission_id: str, + self, + impl_data: Dict, + user: Agent, + submission_id: str, ) -> object: """Get information about a ui-app.""" ... @abstractmethod async def begin( - self, - impl_data: Dict, - + self, + impl_data: Dict, + user: Agent, ) -> str: """Start a ui-app and get a ui-app ID.""" ... @abstractmethod async def submission_id_accept_policy_post( - self, - impl_data: Dict, - submission_id: str, - agreement: Agreement, + self, + impl_data: Dict, + user: Agent, + submission_id: str, + agreement: Agreement, ) -> object: """Agree to an arXiv policy to initiate a new item ui-app or a change to an existing item. """ ... @abstractmethod async def submission_id_deposited_post( - self, - impl_data: Dict, - submission_id: str, + self, + impl_data: Dict, + user: Agent, + submission_id: str, ) -> None: """The ui-app has been successfully deposited by an external service.""" ... @abstractmethod async def submission_id_mark_processing_for_deposit_post( - self, - impl_data: Dict, - submission_id: str, + self, + impl_data: Dict, + user: Agent, + submission_id: str, ) -> None: """Mark that the ui-app is being processed for deposit.""" ... @abstractmethod async def submission_id_unmark_processing_for_deposit_post( - self, - impl_data: Dict, - submission_id: str, + self, + impl_data: Dict, + user: Agent, + submission_id: str, ) -> None: """Indicate that an external system in no longer working on depositing this ui-app. This does not indicate that is was successfully deposited. """ ... + + @abstractmethod + async def get_service_status( + self, + impl_data: Dict, + ) -> Tuple[bool, str]: + """Service health.""" + ... diff --git a/submit_ce/submit_fastapi/auth.py b/submit_ce/submit_fastapi/auth.py new file mode 100644 index 0000000..a4b50fa --- /dev/null +++ b/submit_ce/submit_fastapi/auth.py @@ -0,0 +1,11 @@ +from typing import Optional + +from fastapi import HTTPException, status + + +from submit_ce.domain.agent import Agent + + +async def get_user() -> Optional[Agent]: + # TODO some kind of implementation + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 4209388..37d6d5d 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -6,6 +6,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from submit_ce.domain.agent import Agent from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi from submit_ce.submit_fastapi.api.models.agreement import Agreement from submit_ce.submit_fastapi.config import Settings @@ -46,27 +47,27 @@ def legacy_depends(db=Depends(get_db)) -> dict: class LegacySubmitImplementation(BaseDefaultApi): - async def get_submission(self, impl_data: Dict, submission_id: str) -> object: + async def get_submission(self, impl_data: Dict, user: Agent, submission_id: str) -> object: pass - async def begin(self, impl_data: Dict) -> str: - pass + async def begin(self, impl_data: Dict, user: Agent) -> str: + return "bogus_id" - async def submission_id_accept_policy_post(self, impl_data: Dict, submission_id: str, + async def submission_id_accept_policy_post(self, impl_data: Dict, user: Agent, + submission_id: str, agreement: Agreement) -> object: pass - async def submission_id_deposited_post(self, impl_data: Dict, submission_id: str) -> None: + async def submission_id_deposited_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: pass - async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, submission_id: str) -> None: + async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: pass - async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, submission_id: str) -> None: + async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: pass async def get_service_status(self, impl_data: dict): - logger.info("Here in get_service_status") return f"{self.__class__.__name__} impl_data: {impl_data}" diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 1476437..b3b68c2 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -8,18 +8,9 @@ def test_get_service_status(client: TestClient): """Test case for get_service_status""" - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "GET", - # "/status", - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 + headers = {} + response = client.request("GET", "/status", headers=headers) + assert response.status_code == 200 def test_get_submission(client: TestClient): @@ -41,23 +32,15 @@ def test_get_submission(client: TestClient): #assert response.status_code == 200 -def test_new(client: TestClient): - """Test case for new - - - """ - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "POST", - # "/", - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 +def test_begin(client: TestClient): + """Test case for begin.""" + headers = { } + response = client.request("POST", "/", headers=headers) + # assert response.status_code == 200 + # assert response.text + # submission_id = response.text + # response = client.request(f"/{submission_id}") + # assert response.status_code == 200 def test_submission_id_accept_policy_post(client: TestClient): From b517b0c914b7efa0c611c7d1ca9ec1094b045999 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 08:29:37 -0400 Subject: [PATCH 12/28] WIP db write of start of submission --- submit_ce/submit_fastapi/api/default_api.py | 18 +-- .../submit_fastapi/api/default_api_base.py | 11 +- .../api/models}/agent.py | 7 +- .../submit_fastapi/api/models/agreement.py | 98 --------------- .../submit_fastapi/api/models/event_info.py | 21 ---- .../api/models/events/__init__.py | 113 ++++++++++++++++++ submit_ce/submit_fastapi/app.py | 9 +- submit_ce/submit_fastapi/auth.py | 15 ++- submit_ce/submit_fastapi/config.py | 1 + .../implementations/legacy_implementation.py | 69 +++++++---- tests/conftest.py | 27 +---- tests/test_default_api.py | 7 +- 12 files changed, 208 insertions(+), 188 deletions(-) rename submit_ce/{domain => submit_fastapi/api/models}/agent.py (94%) delete mode 100644 submit_ce/submit_fastapi/api/models/agreement.py delete mode 100644 submit_ce/submit_fastapi/api/models/event_info.py create mode 100644 submit_ce/submit_fastapi/api/models/events/__init__.py diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index b9616cc..6880147 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -1,6 +1,6 @@ # coding: utf-8 -from typing import Dict, List, Callable, Annotated # noqa: F401 +from typing import Dict, List, Callable, Annotated, Union, Literal # noqa: F401 from fastapi import ( # noqa: F401 APIRouter, @@ -16,14 +16,14 @@ Security, status, ) +from pydantic import BaseModel, Field from submit_ce.submit_fastapi.config import config from .default_api_base import BaseDefaultApi -from .models.agreement import Agreement +from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising from ..auth import get_user from ..implementations import ImplementationConfig -from ...domain.agent import Agent if not isinstance(config.submission_api_implementation, ImplementationConfig): raise ValueError("submission_api_implementation must be of class ImplementationConfig.") @@ -34,10 +34,10 @@ impl_depends: Callable = config.submission_api_implementation.depends_fn """A depends the implementation depends on.""" -userDep = Annotated[Agent, Depends(get_user)] +userDep = Depends(get_user) router = APIRouter() - +router.prefix="/v1" @router.get( "/status", @@ -54,6 +54,7 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: return await implementation.get_service_status(impl_dep) + @router.post( "/", responses={ @@ -62,7 +63,7 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: tags=["submit"], response_model_by_alias=True, ) -async def begin(impl_dep=Depends(impl_depends), user=userDep) -> str: +async def start(started: Union[StartedNew, StartedAlterExising], impl_dep=Depends(impl_depends), user=userDep) -> str: """Start a submission and get a submission ID. TODO Maybe the start needs to include accepting an agreement? @@ -70,8 +71,7 @@ async def begin(impl_dep=Depends(impl_depends), user=userDep) -> str: TODO parameters for new,replacement,withdraw,cross,jref TODO How to better indicate that the body is a string that is the submission id? Links?""" - return await implementation.begin(impl_dep, user) - + return await implementation.start(started, impl_dep, user) @router.get( "/{submission_id}", @@ -103,7 +103,7 @@ async def get_submission( ) async def submission_id_accept_policy_post( submission_id: str = Path(..., description="Id of the submission to get."), - agreement: Agreement = Body(None, description=""), + agreement: AgreedToPolicy = Body(None, description=""), impl_dep: dict = Depends(impl_depends), user=userDep ) -> object: diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py index c4e6b81..eb53d97 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -1,10 +1,10 @@ # coding: utf-8 from abc import ABC, abstractmethod -from typing import ClassVar, Dict, List, Tuple # noqa: F401 +from typing import ClassVar, Dict, List, Tuple, Union # noqa: F401 -from .models.agreement import Agreement -from ...domain.agent import Agent +from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew +from submit_ce.submit_fastapi.api.models.agent import Agent class BaseDefaultApi(ABC): @@ -20,8 +20,9 @@ async def get_submission( ... @abstractmethod - async def begin( + async def start( self, + started: Union[StartedNew], impl_data: Dict, user: Agent, ) -> str: @@ -34,7 +35,7 @@ async def submission_id_accept_policy_post( impl_data: Dict, user: Agent, submission_id: str, - agreement: Agreement, + agreement: AgreedToPolicy, ) -> object: """Agree to an arXiv policy to initiate a new item ui-app or a change to an existing item. """ ... diff --git a/submit_ce/domain/agent.py b/submit_ce/submit_fastapi/api/models/agent.py similarity index 94% rename from submit_ce/domain/agent.py rename to submit_ce/submit_fastapi/api/models/agent.py index f60545c..f8f3388 100644 --- a/submit_ce/domain/agent.py +++ b/submit_ce/submit_fastapi/api/models/agent.py @@ -13,7 +13,12 @@ class Agent(BaseModel): """ native_id: str - """Type-specific identifier for the agent. This might be an URI.""" + """ + Type-specific identifier for the agent. + + In legacy this will be the tapir user_id + This might be an URI. + """ hostname: Optional[str] = Field(default=None) """Hostname or IP address from which user requests are originating.""" diff --git a/submit_ce/submit_fastapi/api/models/agreement.py b/submit_ce/submit_fastapi/api/models/agreement.py deleted file mode 100644 index 40411a3..0000000 --- a/submit_ce/submit_fastapi/api/models/agreement.py +++ /dev/null @@ -1,98 +0,0 @@ -# coding: utf-8 - -""" - arxiv submit - - No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - - The version of the OpenAPI document: 0.1 - Contact: nextgen@arxiv.org - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - - - - -from pydantic import BaseModel, StrictStr -from typing import Any, ClassVar, Dict, List - -from .event_info import EventInfo - -try: - from typing import Self -except ImportError: - from typing_extensions import Self - -class Agreement(BaseModel): - """ - The sender of this request agrees to the statement in the agreement - """ # noqa: E501 - submission_id: StrictStr - accepted_policy: StrictStr - event_info: EventInfo - __properties: ClassVar[List[str]] = ["submission_id", "name", "agreement"] - - model_config = { - "populate_by_name": True, - "validate_assignment": True, - "protected_namespaces": (), - } - - - def to_str(self) -> str: - """Returns the string representation of the model using alias""" - return pprint.pformat(self.model_dump(by_alias=True)) - - def to_json(self) -> str: - """Returns the JSON representation of the model using alias""" - # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead - return json.dumps(self.to_dict()) - - @classmethod - def from_json(cls, json_str: str) -> Self: - """Create an instance of Agreement from a JSON string""" - return cls.from_dict(json.loads(json_str)) - - def to_dict(self) -> Dict[str, Any]: - """Return the dictionary representation of the model using alias. - - This has the following differences from calling pydantic's - `self.model_dump(by_alias=True)`: - - * `None` is only added to the output dict for nullable fields that - were set at model initialization. Other fields with value `None` - are ignored. - """ - _dict = self.model_dump( - by_alias=True, - exclude={ - }, - exclude_none=True, - ) - return _dict - - @classmethod - def from_dict(cls, obj: Dict) -> Self: - """Create an instance of Agreement from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "submission_id": obj.get("submission_id"), - "name": obj.get("name"), - "agreement": obj.get("agreement") - }) - return _obj - - diff --git a/submit_ce/submit_fastapi/api/models/event_info.py b/submit_ce/submit_fastapi/api/models/event_info.py deleted file mode 100644 index 356f9a0..0000000 --- a/submit_ce/submit_fastapi/api/models/event_info.py +++ /dev/null @@ -1,21 +0,0 @@ -# coding: utf-8 - -""" - event info - - Basic information about an event. -""" # noqa: E501 - - -from __future__ import annotations -import re -from datetime import datetime - -from pydantic import BaseModel - - -class EventInfo(BaseModel): - event_id: str - recorded: datetime - submission_id: str - user_id: str \ No newline at end of file diff --git a/submit_ce/submit_fastapi/api/models/events/__init__.py b/submit_ce/submit_fastapi/api/models/events/__init__.py new file mode 100644 index 0000000..54c8ad5 --- /dev/null +++ b/submit_ce/submit_fastapi/api/models/events/__init__.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import pprint +from typing import Optional, Any, Dict, Literal + +from pydantic import BaseModel, AwareDatetime, StrictStr + +from submit_ce.submit_fastapi.api.models.agent import Agent + + +class BaseEvent(BaseModel): + event_info: EventInfo + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + _dict = self.model_dump( + by_alias=True, + exclude={ + }, + exclude_none=True, + ) + return _dict + + +class EventInfo(BaseModel): + event_id: str + submission_id: str + user: Agent + """ + The agent responsible for the operation represented by this event. + + This may be any type of agent. + + This is **not** necessarily the creator of the submission. + """ + + recorded: Optional[AwareDatetime] + """ + When the event was originally recorded in the system. + + Must have a timezone. + """ + + proxy: Optional[Agent] + """ + The agent who facilitated the operation on behalf of the :attr:`.creator`. + + This may be an API client, or another user who has been designated as a + proxy. Note that proxy implies that the creator was not directly involved. + """ + + client: Optional[Agent] + """ + The client through which the :attr:`.creator` performed the operation. + + If the creator was directly involved in the operation, this property should + be the client that facilitated the operation. + """ + + +class AgreedToPolicy(BaseEvent): + """ + The sender of this request agrees to the statement in the agreement. + """ + accepted_policy: StrictStr + + +class StartedNew(BaseModel): + """ + Starts a submission. + """ + submission_type: Literal["new"] + """What does the submission change in the system? + + `new`: deposit a new item or paper as version 1. + `replacement`: deposit a version that supersedes the latest version on an existing paper. + `withdrawal`: mark a version of a paper as no longer valid. + `cross`: add a category to an existing paper. + `jref`: add a jref to an existing paper. + """ + + +class StartedAlterExising(BaseModel): + """ + Starts a submission. + """ + submission_type: Literal["replacement", "withdrawal", "cross", "jref"] + """What does the submission change in the system? + + `new`: deposit a new item or paper as version 1. + `replacement`: deposit a version that supersedes the latest version on an existing paper. + `withdrawal`: mark a version of a paper as no longer valid. + `cross`: add a category to an existing paper. + `jref`: add a jref to an existing paper. + """ + + paperid: str + """The existing paper that is modified. Only valid for replacement, withdrawal, and jref and cross""" + diff --git a/submit_ce/submit_fastapi/app.py b/submit_ce/submit_fastapi/app.py index 5b98285..74bf130 100644 --- a/submit_ce/submit_fastapi/app.py +++ b/submit_ce/submit_fastapi/app.py @@ -1,6 +1,10 @@ +import os + from fastapi import FastAPI from submit_ce.submit_fastapi.api.default_api import router as DefaultApiRouter -from .config import config +from .config import config, DEV_SQLITE_FILE +from arxiv.config import settings + app = FastAPI( title="arxiv submit", @@ -9,5 +13,8 @@ ) app.state.config = config +if not os.environ.get("CLASSIC_DB_URI", None): + settings.CLASSIC_DB_URI = "sqlite:///{DEV_SQLITE_FILE}" + config.submission_api_implementation.setup_fn(config) app.include_router(DefaultApiRouter) diff --git a/submit_ce/submit_fastapi/auth.py b/submit_ce/submit_fastapi/auth.py index a4b50fa..ad1a049 100644 --- a/submit_ce/submit_fastapi/auth.py +++ b/submit_ce/submit_fastapi/auth.py @@ -1,11 +1,22 @@ +import uuid from typing import Optional from fastapi import HTTPException, status -from submit_ce.domain.agent import Agent +from submit_ce.submit_fastapi.api.models.agent import Agent, User async def get_user() -> Optional[Agent]: # TODO some kind of implementation - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + #raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return User(native_id=str(uuid.uuid4()), + name="Redundent?", + first_name="Bob", + last_name="Smith", + surname="SURn", + affiliation=str(uuid.uuid4()), + username=str(uuid.uuid4()), + email=str(uuid.uuid4()), + endorsements=[] + ) \ No newline at end of file diff --git a/submit_ce/submit_fastapi/config.py b/submit_ce/submit_fastapi/config.py index efd5f06..2534f63 100644 --- a/submit_ce/submit_fastapi/config.py +++ b/submit_ce/submit_fastapi/config.py @@ -4,6 +4,7 @@ from pydantic import SecretStr, ImportString +DEV_SQLITE_FILE="legacy.db" class Settings(BaseSettings): """CLASSIC_DB_URI and other configs are from arxiv-base arxiv.config.""" diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 37d6d5d..109b8c7 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -1,37 +1,36 @@ import logging -from typing import Dict +from typing import Dict, Union from arxiv.config import settings -from fastapi import Depends -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +import arxiv.db +from arxiv.db.models import Submission, Document, configure_db_engine +from fastapi import Depends, HTTPException, status +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession -from submit_ce.domain.agent import Agent +from submit_ce.submit_fastapi.api.models.agent import Agent, User from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi -from submit_ce.submit_fastapi.api.models.agreement import Agreement +from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising from submit_ce.submit_fastapi.config import Settings from submit_ce.submit_fastapi.implementations import ImplementationConfig logger = logging.getLogger(__name__) -_sessionLocal = sessionmaker(autocommit=False, autoflush=False) +_setup = False - -def get_sessionlocal(): - global _sessionLocal - if _sessionLocal is None: +def get_session(): + """Dependency for fastapi routes""" + global _setup + if not _setup: if 'sqlite' in settings.CLASSIC_DB_URI: args = {"check_same_thread": False} else: args = {} engine = create_engine(settings.CLASSIC_DB_URI, echo=settings.ECHO_SQL, connect_args=args) - _sessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + arxiv.db.session_factory = sessionmaker(autoflush=False, bind=engine) + configure_db_engine(engine, None) - return _sessionLocal - -def get_db(session_local=Depends(get_sessionlocal)): - """Dependency for fastapi routes""" - with session_local() as session: + with arxiv.db.session_factory() as session: try: yield session if session.begin or session.dirty or session.deleted: @@ -41,21 +40,45 @@ def get_db(session_local=Depends(get_sessionlocal)): raise -def legacy_depends(db=Depends(get_db)) -> dict: - return {"db": db} +def legacy_depends(db=Depends(get_session)) -> dict: + return {"session": db} class LegacySubmitImplementation(BaseDefaultApi): async def get_submission(self, impl_data: Dict, user: Agent, submission_id: str) -> object: - pass + session = impl_data["session"] + submssion = session.scalars(select(Submission).where(submission_id=submission_id)) + if submssion: + return submssion + else: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + async def start(self, started: Union[StartedNew, StartedAlterExising], impl_data: Dict, user: User) -> str: + session = impl_data["session"] + submission = Submission(stage=0, + submitter_id=user.native_id, + submitter_name=user.get_name(), + type=started.submission_type) + if isinstance(started, StartedAlterExising): + doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="Existing paper not found.") + elif doc.submitter_id != user.native_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Not submitter of existing paper.") + else: + submission.document_id = doc.document_id + submission.doc_paper_id = doc.paper_id + + session.add(submission) + session.commit() + return submission.submission_id + - async def begin(self, impl_data: Dict, user: Agent) -> str: - return "bogus_id" async def submission_id_accept_policy_post(self, impl_data: Dict, user: Agent, submission_id: str, - agreement: Agreement) -> object: + agreement: AgreedToPolicy) -> object: pass async def submission_id_deposited_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 12e5653..2d05c40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,34 +6,13 @@ from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session +from make_test_db import legacy_db -@pytest.fixture(scope='session') -def test_dir(): - db_path = tempfile.mkdtemp() - yield db_path - shutil.rmtree(db_path) - -@pytest.fixture(scope='session') -def classic_db(test_dir, echo: bool=False) -> None: - """Temp classic db with all tables created but no data.""" - url = f"sqlite:///{test_dir}/test_classic.db" - engine = create_engine(url, echo=echo) - from arxiv.db.models import configure_db_engine - configure_db_engine(engine, None) - from arxiv.db import metadata - with Session(engine) as session: - import arxiv.db.models as models - models.configure_db_engine(session.get_bind(), None) - metadata.create_all(bind=engine) - session.commit() - - yield (engine, url, test_dir) - @pytest.fixture -def app(test_dir, classic_db) -> FastAPI: - engine, url, test_dir = classic_db +def app(legacy_db) -> FastAPI: + engine, url, test_db_file = legacy_db from arxiv.config import settings settings.CLASSIC_DB_URI = url diff --git a/tests/test_default_api.py b/tests/test_default_api.py index b3b68c2..16cd897 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -2,14 +2,13 @@ from fastapi.testclient import TestClient - -from submit_ce.submit_fastapi.api.models.agreement import Agreement # noqa: F401 +from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy def test_get_service_status(client: TestClient): """Test case for get_service_status""" headers = {} - response = client.request("GET", "/status", headers=headers) + response = client.request("GET", "/v1/status", headers=headers) assert response.status_code == 200 @@ -32,7 +31,7 @@ def test_get_submission(client: TestClient): #assert response.status_code == 200 -def test_begin(client: TestClient): +def test_start(client: TestClient): """Test case for begin.""" headers = { } response = client.request("POST", "/", headers=headers) From 0e0fd366f415ac2493426a0534dc0f1cbd9f2b3e Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 09:04:01 -0400 Subject: [PATCH 13/28] WIP db write of start of submission Adds make_test_db.py --- .gitignore | 6 +++-- .idea/.gitignore | 8 ++++++ README.md | 3 +++ poetry.lock | 30 ++++++++++++++++++++- pyproject.toml | 3 ++- submit_ce/submit_fastapi/api/default_api.py | 1 - tests/conftest.py | 19 ++++++++++--- tests/make_test_db.py | 29 ++++++++++++++++++++ 8 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 tests/make_test_db.py diff --git a/.gitignore b/.gitignore index 86cd75e..c281470 100644 --- a/.gitignore +++ b/.gitignore @@ -85,7 +85,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -162,4 +162,6 @@ cython_debug/ #.idea/ # morbund NG arxiv code -graveyard/ \ No newline at end of file +graveyard/ + +legacy.db \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/README.md b/README.md index bb68486..5f8ba41 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ To run the server, please execute the following from the root directory: ```bash poetry install +# TODO How to get arxiv-base? +poetry shell # or however you do venvs +python test/make_test_db.py # to make sqlite dev db fastapi dev src/arxiv/submit_fastapi/main.py ``` diff --git a/poetry.lock b/poetry.lock index b6b7547..0457a00 100644 --- a/poetry.lock +++ b/poetry.lock @@ -488,6 +488,20 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] standard = ["uvicorn[standard] (>=0.15.0)"] +[[package]] +name = "fire" +version = "0.6.0" +description = "A library for automatically generating command line interfaces." +optional = false +python-versions = "*" +files = [ + {file = "fire-0.6.0.tar.gz", hash = "sha256:54ec5b996ecdd3c0309c800324a0703d6da512241bc73b553db959d98de0aa66"}, +] + +[package.dependencies] +six = "*" +termcolor = "*" + [[package]] name = "greenlet" version = "3.1.0" @@ -1929,6 +1943,20 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "termcolor" +version = "2.4.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.8" +files = [ + {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, + {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + [[package]] name = "typer" version = "0.12.5" @@ -2413,4 +2441,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "b8c15e41cb84d9b47cfb8752003603a9ad1ef6afae8af6b1bd9287994b1675e4" +content-hash = "ee2841f61aa4fe4a930074db3a1ea200d42b31813f4fef2365baafa21722377f" diff --git a/pyproject.toml b/pyproject.toml index f990f87..7e25e92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ packages = [{include = "arxiv", from="src"}] [tool.poetry.dependencies] python = "^3.11" -#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", branch = "develop" } +#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", ref = "f8827cc8" } backports-datetime-fromisoformat = "*" jsonschema = "*" @@ -35,6 +35,7 @@ requests-toolbelt = "^1.0.0" pydantic-settings = "^2.5.2" fastapi = {extras = ["all"], version = "^0.114.2"} sqlalchemy = "^2.0.34" +fire = "^0.6.0" [tool.poetry.group.dev.dependencies] coverage = "*" diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index 6880147..02b7025 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -50,7 +50,6 @@ ) async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: """Get information about the current status of file management service.""" - print("Here in default_api get_service_status") return await implementation.get_service_status(impl_dep) diff --git a/tests/conftest.py b/tests/conftest.py index 2d05c40..4a41c12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,22 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import Session -from make_test_db import legacy_db +from submit_ce.submit_fastapi.config import DEV_SQLITE_FILE +from tests.make_test_db import create_all_legacy_db + + + +@pytest.fixture(scope='session') +def test_db_file(): + db_path = tempfile.mkdtemp() + yield db_path + "/" + DEV_SQLITE_FILE + shutil.rmtree(db_path) + + +@pytest.fixture(scope='session') +def legacy_db(test_db_file): + return create_all_legacy_db(test_db_file) @pytest.fixture @@ -25,3 +37,4 @@ def app(legacy_db) -> FastAPI: @pytest.fixture def client(app) -> TestClient: return TestClient(app) + diff --git a/tests/make_test_db.py b/tests/make_test_db.py new file mode 100644 index 0000000..2bf1ba4 --- /dev/null +++ b/tests/make_test_db.py @@ -0,0 +1,29 @@ +if __name__ == '__main__': + import sys + from pathlib import Path + sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +import fire +from submit_ce.submit_fastapi.config import DEV_SQLITE_FILE + + +def create_all_legacy_db(test_db_file: str=DEV_SQLITE_FILE, echo: bool=False): + """Legacy db with all tables created but no data.""" + url = f"sqlite:///{test_db_file}" + engine = create_engine(url, echo=echo) + from arxiv.db.models import configure_db_engine + configure_db_engine(engine, None) + from arxiv.db import metadata + with Session(engine) as session: + import arxiv.db.models as models + models.configure_db_engine(session.get_bind(), None) + metadata.create_all(bind=engine) + session.commit() + + return (engine, url, test_db_file) + + +if __name__ == "__main__": + fire.Fire(create_all_legacy_db) From 74a99aeb6e2e4eacc8da46a3e60f12c9cc4f3227 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 12:21:27 -0400 Subject: [PATCH 14/28] db write of start of submission Can make a new submission, and it is written to legacy db. Adds make_test_db.py Simplifies Agent,User,Client --- submit_ce/submit_fastapi/api/default_api.py | 29 ++++--- .../submit_fastapi/api/default_api_base.py | 22 +++-- submit_ce/submit_fastapi/api/models/agent.py | 87 +++---------------- .../api/models/events/__init__.py | 9 +- submit_ce/submit_fastapi/app.py | 2 +- submit_ce/submit_fastapi/auth.py | 17 +++- .../implementations/legacy_implementation.py | 31 ++++--- 7 files changed, 81 insertions(+), 116 deletions(-) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index 02b7025..38b27e2 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -22,7 +22,7 @@ from .default_api_base import BaseDefaultApi from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising -from ..auth import get_user +from ..auth import get_user, get_client from ..implementations import ImplementationConfig if not isinstance(config.submission_api_implementation, ImplementationConfig): @@ -35,6 +35,7 @@ """A depends the implementation depends on.""" userDep = Depends(get_user) +clentDep = Depends(get_client) router = APIRouter() router.prefix="/v1" @@ -62,7 +63,9 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: tags=["submit"], response_model_by_alias=True, ) -async def start(started: Union[StartedNew, StartedAlterExising], impl_dep=Depends(impl_depends), user=userDep) -> str: +async def start(started: Union[StartedNew, StartedAlterExising], + impl_dep=Depends(impl_depends), user=userDep, client=clentDep, + ) -> str: """Start a submission and get a submission ID. TODO Maybe the start needs to include accepting an agreement? @@ -70,7 +73,7 @@ async def start(started: Union[StartedNew, StartedAlterExising], impl_dep=Depend TODO parameters for new,replacement,withdraw,cross,jref TODO How to better indicate that the body is a string that is the submission id? Links?""" - return await implementation.start(started, impl_dep, user) + return await implementation.start(impl_dep, user, client, started) @router.get( "/{submission_id}", @@ -82,10 +85,10 @@ async def start(started: Union[StartedNew, StartedAlterExising], impl_dep=Depend ) async def get_submission( submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep=Depends(impl_depends), user=userDep + impl_dep=Depends(impl_depends), user=userDep, client=clentDep ) -> object: """Get information about a submission.""" - return await implementation.get_submission(impl_dep, user, submission_id) + return await implementation.get_submission(impl_dep, user, client, submission_id) @router.post( @@ -104,10 +107,10 @@ async def submission_id_accept_policy_post( submission_id: str = Path(..., description="Id of the submission to get."), agreement: AgreedToPolicy = Body(None, description=""), impl_dep: dict = Depends(impl_depends), - user=userDep + user=userDep, client=clentDep ) -> object: """Agree to an arXiv policy to initiate a new item submission or a change to an existing item. """ - return await implementation.submission_id_accept_policy_post(impl_dep, user, submission_id, agreement) + return await implementation.submission_id_accept_policy_post(impl_dep, user, client, submission_id, agreement) @router.post( @@ -120,10 +123,10 @@ async def submission_id_accept_policy_post( ) async def submission_id_deposited_post( submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), user=userDep + impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: """The submission has been successfully deposited by an external service.""" - return await implementation.submission_id_deposited_post(impl_dep, user, submission_id) + return await implementation.submission_id_deposited_post(impl_dep, user, client, submission_id) @router.post( @@ -136,10 +139,10 @@ async def submission_id_deposited_post( ) async def submission_id_mark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), user=userDep + impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: """Mark that the submission is being processed for deposit.""" - return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, user, submission_id) + return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, user, client, submission_id) @router.post( @@ -152,10 +155,10 @@ async def submission_id_mark_processing_for_deposit_post( ) async def submission_id_unmark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), - impl_dep: dict = Depends(impl_depends), user=userDep + impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: """Indicate that an external system in no longer working on depositing this submission. This just indicates that the submission is no longer in processing state. This does not indicate that it was successfully deposited. """ - return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, user, submission_id) + return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, user, client, submission_id) diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py index eb53d97..918be3c 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -4,7 +4,7 @@ from typing import ClassVar, Dict, List, Tuple, Union # noqa: F401 from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew -from submit_ce.submit_fastapi.api.models.agent import Agent +from submit_ce.submit_fastapi.api.models.agent import User, Client class BaseDefaultApi(ABC): @@ -13,7 +13,8 @@ class BaseDefaultApi(ABC): async def get_submission( self, impl_data: Dict, - user: Agent, + user: User, + client: Client, submission_id: str, ) -> object: """Get information about a ui-app.""" @@ -22,9 +23,10 @@ async def get_submission( @abstractmethod async def start( self, - started: Union[StartedNew], impl_data: Dict, - user: Agent, + user: User, + client: Client, + started: Union[StartedNew], ) -> str: """Start a ui-app and get a ui-app ID.""" ... @@ -33,7 +35,8 @@ async def start( async def submission_id_accept_policy_post( self, impl_data: Dict, - user: Agent, + user: User, + client: Client, submission_id: str, agreement: AgreedToPolicy, ) -> object: @@ -44,7 +47,8 @@ async def submission_id_accept_policy_post( async def submission_id_deposited_post( self, impl_data: Dict, - user: Agent, + user: User, + client: Client, submission_id: str, ) -> None: """The ui-app has been successfully deposited by an external service.""" @@ -54,7 +58,8 @@ async def submission_id_deposited_post( async def submission_id_mark_processing_for_deposit_post( self, impl_data: Dict, - user: Agent, + user: User, + client: Client, submission_id: str, ) -> None: """Mark that the ui-app is being processed for deposit.""" @@ -64,7 +69,8 @@ async def submission_id_mark_processing_for_deposit_post( async def submission_id_unmark_processing_for_deposit_post( self, impl_data: Dict, - user: Agent, + user: User, + client: Client, submission_id: str, ) -> None: """Indicate that an external system in no longer working on depositing this ui-app. This does not indicate that is was successfully deposited. """ diff --git a/submit_ce/submit_fastapi/api/models/agent.py b/submit_ce/submit_fastapi/api/models/agent.py index f8f3388..74c0bd8 100644 --- a/submit_ce/submit_fastapi/api/models/agent.py +++ b/submit_ce/submit_fastapi/api/models/agent.py @@ -2,91 +2,30 @@ from typing import Any, Optional, List, Union, Type, Dict -from dataclasses import dataclass, field - from pydantic import BaseModel, Field -class Agent(BaseModel): - """ - Base class for actors that are responsible for events. - """ - - native_id: str - """ - Type-specific identifier for the agent. - - In legacy this will be the tapir user_id - This might be an URI. - """ - - hostname: Optional[str] = Field(default=None) - """Hostname or IP address from which user requests are originating.""" +class User(BaseModel): + """A human end user.""" - name: str username: str + forename: str + surname: str + suffix: str + identifier: Optional[str] = Field(default=None) + affiliation: str email: str endorsements: List[str] = Field(default_factory=list) - @classmethod - def get_agent_type(cls) -> str: - """Get the name of the instance's class.""" - return cls.__name__ - - def __eq__(self, other: Any) -> bool: - """Equality comparison for agents based on type and identifier.""" - # if not isinstance(other, self.__class__): - # return False - return self.native_id == other.native_id - - -class User(Agent): - """A human end user.""" - - forename: str = field(default_factory=str) - surname: str = field(default_factory=str) - suffix: str = field(default_factory=str) - identifier: Optional[str] = field(default=None) - affiliation: str = field(default_factory=str) - def get_name(self) -> str: """Full name of the user.""" return f"{self.forename} {self.surname} {self.suffix}" -class System(Agent): - """The submission application.""" - pass - - -@dataclass -class Client(Agent): - """A non-human third party, usually an API client.""" - pass - - -_agent_types: Dict[str, Type[Agent]] = { - User.get_agent_type(): User, - System.get_agent_type(): System, - Client.get_agent_type(): Client, -} - - -def agent_factory(**data: Union[Agent, dict]) -> Agent: - """Instantiate a subclass of :class:`.Agent`.""" - if isinstance(data, Agent): - return data - agent_type = str(data.pop('agent_type')) - native_id = data.pop('native_id') - if not agent_type or not native_id: - raise ValueError('No such agent: %s, %s' % (agent_type, native_id)) - if agent_type not in _agent_types: - raise ValueError(f'No such agent type: {agent_type}') - # Mypy chokes on meta-stuff like this. One of the goals of this factory - # function is to not have to write code for each agent subclass. We can - # revisit this in the future. For now, this code is correct, it just isn't - # easy to type-check. - klass = _agent_types[agent_type] - data = {k: v for k, v in data.items() if k in klass.__dataclass_fields__} - return klass(native_id, **data) +class Client(BaseModel): + """A non-human tool that is making requests to the submit API, usually an API client.""" + remoteAddress: str + remoteHost: str + agent_type: str + agent_version: str diff --git a/submit_ce/submit_fastapi/api/models/events/__init__.py b/submit_ce/submit_fastapi/api/models/events/__init__.py index 54c8ad5..32da3fc 100644 --- a/submit_ce/submit_fastapi/api/models/events/__init__.py +++ b/submit_ce/submit_fastapi/api/models/events/__init__.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, AwareDatetime, StrictStr -from submit_ce.submit_fastapi.api.models.agent import Agent +from submit_ce.submit_fastapi.api.models.agent import User, Client class BaseEvent(BaseModel): @@ -39,7 +39,8 @@ def to_dict(self) -> Dict[str, Any]: class EventInfo(BaseModel): event_id: str submission_id: str - user: Agent + user: User + """ The agent responsible for the operation represented by this event. @@ -55,7 +56,7 @@ class EventInfo(BaseModel): Must have a timezone. """ - proxy: Optional[Agent] + proxy: Optional[User] """ The agent who facilitated the operation on behalf of the :attr:`.creator`. @@ -63,7 +64,7 @@ class EventInfo(BaseModel): proxy. Note that proxy implies that the creator was not directly involved. """ - client: Optional[Agent] + client: Client """ The client through which the :attr:`.creator` performed the operation. diff --git a/submit_ce/submit_fastapi/app.py b/submit_ce/submit_fastapi/app.py index 74bf130..ab066e4 100644 --- a/submit_ce/submit_fastapi/app.py +++ b/submit_ce/submit_fastapi/app.py @@ -14,7 +14,7 @@ app.state.config = config if not os.environ.get("CLASSIC_DB_URI", None): - settings.CLASSIC_DB_URI = "sqlite:///{DEV_SQLITE_FILE}" + settings.CLASSIC_DB_URI = f"sqlite:///{DEV_SQLITE_FILE}" config.submission_api_implementation.setup_fn(config) app.include_router(DefaultApiRouter) diff --git a/submit_ce/submit_fastapi/auth.py b/submit_ce/submit_fastapi/auth.py index ad1a049..847cd13 100644 --- a/submit_ce/submit_fastapi/auth.py +++ b/submit_ce/submit_fastapi/auth.py @@ -4,19 +4,30 @@ from fastapi import HTTPException, status -from submit_ce.submit_fastapi.api.models.agent import Agent, User +from submit_ce.submit_fastapi.api.models.agent import User, Client -async def get_user() -> Optional[Agent]: +async def get_client() -> Client: + # TODO some kind of implementation + return Client( + remoteAddress="127.0.0.1", + remoteHost="example.com", + agent_type="browser", + agent_version="v223432" + ) + +async def get_user() -> Optional[User]: # TODO some kind of implementation #raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return User(native_id=str(uuid.uuid4()), name="Redundent?", first_name="Bob", + forename="Bob", + suffix="Sr", last_name="Smith", surname="SURn", affiliation=str(uuid.uuid4()), username=str(uuid.uuid4()), email=str(uuid.uuid4()), endorsements=[] - ) \ No newline at end of file + ) diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 109b8c7..088c3dd 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -8,7 +8,7 @@ from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession -from submit_ce.submit_fastapi.api.models.agent import Agent, User +from submit_ce.submit_fastapi.api.models.agent import User, Client from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising from submit_ce.submit_fastapi.config import Settings @@ -46,20 +46,25 @@ def legacy_depends(db=Depends(get_session)) -> dict: class LegacySubmitImplementation(BaseDefaultApi): - async def get_submission(self, impl_data: Dict, user: Agent, submission_id: str) -> object: + async def get_submission(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> object: session = impl_data["session"] - submssion = session.scalars(select(Submission).where(submission_id=submission_id)) - if submssion: - return submssion + stmt = select(Submission).where(Submission.submission_id==submission_id) + submission = session.scalars(stmt).first() + if submission: + return submission.model else: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - async def start(self, started: Union[StartedNew, StartedAlterExising], impl_data: Dict, user: User) -> str: + async def start(self, impl_data: Dict, user: User, client: Client, started: Union[StartedNew, StartedAlterExising]) -> str: session = impl_data["session"] submission = Submission(stage=0, - submitter_id=user.native_id, + submitter_id=user.identifier, submitter_name=user.get_name(), - type=started.submission_type) + remote_addr=client.remoteAddress, + remote_host=client.remoteHost, + type=started.submission_type, + package="TODOwhatisthis" + ) if isinstance(started, StartedAlterExising): doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() if not doc: @@ -72,22 +77,22 @@ async def start(self, started: Union[StartedNew, StartedAlterExising], impl_data session.add(submission) session.commit() - return submission.submission_id + return str(submission.submission_id) - async def submission_id_accept_policy_post(self, impl_data: Dict, user: Agent, + async def submission_id_accept_policy_post(self, impl_data: Dict, user: User, client: Client, submission_id: str, agreement: AgreedToPolicy) -> object: pass - async def submission_id_deposited_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: + async def submission_id_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass - async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: + async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass - async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, user: Agent, submission_id: str) -> None: + async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass async def get_service_status(self, impl_data: dict): From bfc8aa507a99671905b127a50d0303eed0b4757e Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 13:12:16 -0400 Subject: [PATCH 15/28] Working test: DB write of new sub, and then read --- submit_ce/submit_fastapi/api/default_api.py | 4 ++- submit_ce/submit_fastapi/api/models/agent.py | 12 ++++--- submit_ce/submit_fastapi/auth.py | 34 ++++++++++++------- .../implementations/legacy_implementation.py | 9 +++-- tests/test_default_api.py | 17 ++++++---- 5 files changed, 49 insertions(+), 27 deletions(-) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index 38b27e2..af6083d 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -16,6 +16,7 @@ Security, status, ) +from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field from submit_ce.submit_fastapi.config import config @@ -57,8 +58,9 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: @router.post( "/", + response_class=PlainTextResponse, responses={ - 200: {"model": str, "description": "Successfully started a submission."}, + 200: {"description": "Successfully started a submission."}, }, tags=["submit"], response_model_by_alias=True, diff --git a/submit_ce/submit_fastapi/api/models/agent.py b/submit_ce/submit_fastapi/api/models/agent.py index 74c0bd8..9a2bd8f 100644 --- a/submit_ce/submit_fastapi/api/models/agent.py +++ b/submit_ce/submit_fastapi/api/models/agent.py @@ -7,14 +7,16 @@ class User(BaseModel): """A human end user.""" + identifier: Optional[str] = Field(default=None) + """System identifier for the user. Ex a username, user_id or tapir nickname.""" - username: str forename: str surname: str suffix: str - identifier: Optional[str] = Field(default=None) - affiliation: str + email: str + + affiliation: str endorsements: List[str] = Field(default_factory=list) def get_name(self) -> str: @@ -26,6 +28,6 @@ def get_name(self) -> str: class Client(BaseModel): """A non-human tool that is making requests to the submit API, usually an API client.""" remoteAddress: str - remoteHost: str + remoteHost: Optional[str] = Field(default=None) agent_type: str - agent_version: str + agent_version: Optional[str] = Field(default=None) diff --git a/submit_ce/submit_fastapi/auth.py b/submit_ce/submit_fastapi/auth.py index 847cd13..ad02b66 100644 --- a/submit_ce/submit_fastapi/auth.py +++ b/submit_ce/submit_fastapi/auth.py @@ -1,33 +1,43 @@ import uuid from typing import Optional -from fastapi import HTTPException, status +from fastapi import HTTPException, status, Request from submit_ce.submit_fastapi.api.models.agent import User, Client -async def get_client() -> Client: +async def get_client(request: Request) -> Client: # TODO some kind of implementation + + ua = request.headers.get("User-Agent", None) + if ua is None: + agent_type="ua-not-set" + if ua.lower().startswith("mozilla"): + agent_type="browser" + else: + agent_type=ua[:20] + + # todo hostname + return Client( - remoteAddress="127.0.0.1", - remoteHost="example.com", - agent_type="browser", - agent_version="v223432" + remoteAddress=request.client.host, + remoteHost="", + agent_type=agent_type, + # agent_version="v223432" ) + async def get_user() -> Optional[User]: # TODO some kind of implementation #raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - return User(native_id=str(uuid.uuid4()), - name="Redundent?", - first_name="Bob", + return User(identifier="bobsmith", + forename="Bob", suffix="Sr", - last_name="Smith", surname="SURn", + affiliation=str(uuid.uuid4()), - username=str(uuid.uuid4()), - email=str(uuid.uuid4()), + email="bob@example.com", endorsements=[] ) diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 088c3dd..220c962 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -35,8 +35,10 @@ def get_session(): yield session if session.begin or session.dirty or session.deleted: session.commit() + session.close() except Exception: session.rollback() + session.close() raise @@ -48,10 +50,11 @@ class LegacySubmitImplementation(BaseDefaultApi): async def get_submission(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> object: session = impl_data["session"] - stmt = select(Submission).where(Submission.submission_id==submission_id) + stmt = select(Submission).where(Submission.submission_id==int(submission_id)) submission = session.scalars(stmt).first() if submission: - return submission.model + data = {c.name: getattr(submission, c.name) for c in Submission.__table__.columns} + return data else: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) @@ -69,7 +72,7 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="Existing paper not found.") - elif doc.submitter_id != user.native_id: + elif doc.submitter_id != user.identifier: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Not submitter of existing paper.") else: submission.document_id = doc.document_id diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 16cd897..a279893 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -34,12 +34,17 @@ def test_get_submission(client: TestClient): def test_start(client: TestClient): """Test case for begin.""" headers = { } - response = client.request("POST", "/", headers=headers) - # assert response.status_code == 200 - # assert response.text - # submission_id = response.text - # response = client.request(f"/{submission_id}") - # assert response.status_code == 200 + response = client.request("POST", "/v1/", headers=headers, + json={"submission_type":"new"}) + assert response.status_code == 200 + sid = response.text + assert sid is not None + assert '"' not in sid + + response = client.request("GET", f"/v1/{sid}", headers=headers) + assert response.status_code == 200 + data = response.json() + assert str(data['submission_id']) == sid def test_submission_id_accept_policy_post(client: TestClient): From 4e9ebc62c609c9ac1bb304379ca3bfd765dd7c97 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 13:37:53 -0400 Subject: [PATCH 16/28] Changes api paths --- submit_ce/submit_fastapi/api/default_api.py | 18 ++++++++---------- tests/test_default_api.py | 4 ++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index af6083d..f83b9e0 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -57,13 +57,12 @@ async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: @router.post( - "/", + "/start", response_class=PlainTextResponse, responses={ 200: {"description": "Successfully started a submission."}, }, tags=["submit"], - response_model_by_alias=True, ) async def start(started: Union[StartedNew, StartedAlterExising], impl_dep=Depends(impl_depends), user=userDep, client=clentDep, @@ -72,13 +71,12 @@ async def start(started: Union[StartedNew, StartedAlterExising], TODO Maybe the start needs to include accepting an agreement? - TODO parameters for new,replacement,withdraw,cross,jref - TODO How to better indicate that the body is a string that is the submission id? Links?""" return await implementation.start(impl_dep, user, client, started) + @router.get( - "/{submission_id}", + "/submission/{submission_id}", responses={ 200: {"model": object, "description": "The submission data."}, }, @@ -94,12 +92,12 @@ async def get_submission( @router.post( - "/{submission_id}/acceptPolicy", + "/submission/{submission_id}/acceptPolicy", responses={ 200: {"model": object, "description": "The has been accepted."}, 400: {"model": str, "description": "There was an problem when processing the agreement. It was not accepted."}, 401: {"description": "Unauthorized. Missing valid authentication information. The agreement was not accepted."}, - 403: {"description": "Forbidden. Client or user is not authorized to upload. The agreement was not accepted."}, + 403: {"description": "Forbidden. User or client is not authorized to upload. The agreement was not accepted."}, 500: {"description": "Error. There was a problem. The agreement was not accepted."}, }, tags=["submit"], @@ -116,7 +114,7 @@ async def submission_id_accept_policy_post( @router.post( - "/{submission_id}/deposited", + "/submission/{submission_id}/markDeposited", responses={ 200: {"description": "Deposited has been recorded."}, }, @@ -132,7 +130,7 @@ async def submission_id_deposited_post( @router.post( - "/{submission_id}/markProcessingForDeposit", + "/submission/{submission_id}/markProcessingForDeposit", responses={ 200: {"description": "The submission has been marked as in processing for deposit."}, }, @@ -148,7 +146,7 @@ async def submission_id_mark_processing_for_deposit_post( @router.post( - "/{submission_id}/unmarkProcessingForDeposit", + "/submission/{submission_id}/unmarkProcessingForDeposit", responses={ 200: {"description": "The submission has been marked as no longer in processing for deposit."}, }, diff --git a/tests/test_default_api.py b/tests/test_default_api.py index a279893..20e649b 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -34,14 +34,14 @@ def test_get_submission(client: TestClient): def test_start(client: TestClient): """Test case for begin.""" headers = { } - response = client.request("POST", "/v1/", headers=headers, + response = client.request("POST", "/v1/start", headers=headers, json={"submission_type":"new"}) assert response.status_code == 200 sid = response.text assert sid is not None assert '"' not in sid - response = client.request("GET", f"/v1/{sid}", headers=headers) + response = client.request("GET", f"/v1/submission/{sid}", headers=headers) assert response.status_code == 200 data = response.json() assert str(data['submission_id']) == sid From 6ec0e01a41276f40787f228804c1779ff429d3a1 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 15:20:41 -0400 Subject: [PATCH 17/28] Removes early version of openapi.yaml --- schema/openapi.yaml | 210 -------------------- submit_ce/submit_fastapi/api/default_api.py | 2 +- 2 files changed, 1 insertion(+), 211 deletions(-) delete mode 100644 schema/openapi.yaml diff --git a/schema/openapi.yaml b/schema/openapi.yaml deleted file mode 100644 index e040437..0000000 --- a/schema/openapi.yaml +++ /dev/null @@ -1,210 +0,0 @@ -openapi: "3.0.1" -info: - version: "0.1" - title: "arxiv submit" - contact: - name: "arXiv API Team" - # TODO need a non NG email - email: nextgen@arxiv.org - license: - name: MIT - -paths: - /: - post: - operationId: new - description: Start a submission and get a submission ID. - responses: - '200': - description: Successfully started a new submission. - headers: - Location: - description: URL to use to work with the submission. - schema: - type: string - content: - text/plain: - schema: - type: string - - /{submission_id}: - get: - operationId: getSubmission - description: Get information about a submission. - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - responses: - '200': - description: "The submission data." - content: - application/json: - schema: - type: object - # TODO add schema - - /{submission_id}/acceptPolicy: - post: - description: | - Agree to a an arXiv policy to initiate a new item submission or - a change to an existing item. - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Agreement' - responses: - 200: - description: The has been accepted. - content: - application/json: - schema: - $ref: '#/components/schemas/AgreementResponse' - 400: - description: There was an problem when processing the agreement. It was not accepted. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - 401: - description: Unauthorized. Missing valid authentication information. The agreement was not accepted. - 403: - description: Forbidden. Client or user is not authorized to upload. The agreement was not accepted. - '500': - description: Error. There was a problem. The agreement was not accepted. - - - /{submission_id}/markProcessingForDeposit: - post: - description: Mark that the submission is being processed for deposit. - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - responses: - 200: - description: The submission has been marked as in procesing for deposit. - - /{submission_id}/unmarkProcessingForDeposit: - post: - description: | - Indicate that an external system in no longer working on depositing this submission. - This does not indicate that is was successfully deposited. - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - responses: - 200: - description: The submission has been marked as no longer in procesing for deposit. - - /{submission_id}/deposit_packet/{packet_format}: - get: - description: Gets a tar.gz of the current state of the submission. - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - - in: path - name: packet_format - required: true - schema: - type: string - enum: - - legacy - - cev1 - responses: - 200: - description: Returns a tar.gz - headers: - Content-Disposition: - description: Suggests filename - schema: - type: string - content: - application/gzip: - schema: - type: string - format: binary - description: A tar.gz archive with one or more files - - - - /{submission_id}/Deposited: - post: - parameters: - - in: path - name: submission_id - required: true - description: Id of the submission to get. - schema: - type: string - description: The submission has been successfully deposited by an external service. - responses: - 200: - description: Deposited has been recorded. - - - -################### Server informational #################################### - /status: - get: - operationId: getServiceStatus - description: Get information about the current status of file management service. - responses: - '200': - description: "system is working correctly" - '500': - description: "system is not working correctly" - -############################ Components ##################################### -components: - schemas: - Agreement: - description: The sender of this request agrees to the statement in the agreement - type: object - required: [submission_id, name, date, agreement ] - properties: - submission_id: - type: string - name: - type: string - agreement: - type: string - - AgreementResponse: - description: | - Information about an agreement. - type: object - required: [ submission_id, name, date, agreement, agreed_at ] - properties: - submission_id: - type: string - agreed_at: - type: datetime - name: - type: string - agreement: - type: string - Error: - type: string diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index f83b9e0..76fb7bf 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -125,7 +125,7 @@ async def submission_id_deposited_post( submission_id: str = Path(..., description="Id of the submission to get."), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: - """The submission has been successfully deposited by an external service.""" + """Mark that the submission has been successfully deposited into the arxiv corpus.""" return await implementation.submission_id_deposited_post(impl_dep, user, client, submission_id) From 461568f6ca868c728b5657bde74fa5324abf635f Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 15:57:06 -0400 Subject: [PATCH 18/28] Adds to new sub --- submit_ce/submit_fastapi/api/default_api.py | 41 +++--- .../submit_fastapi/api/default_api_base.py | 10 +- .../implementations/legacy_implementation.py | 120 +++++++++++++++--- 3 files changed, 128 insertions(+), 43 deletions(-) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index 76fb7bf..a981f69 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -41,18 +41,6 @@ router = APIRouter() router.prefix="/v1" -@router.get( - "/status", - responses={ - 200: {"description": "system is working correctly"}, - 500: {"description": "system is not working correctly"}, - }, - tags=["service"], - response_model_by_alias=True, -) -async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: - """Get information about the current status of file management service.""" - return await implementation.get_service_status(impl_dep) @@ -103,14 +91,14 @@ async def get_submission( tags=["submit"], response_model_by_alias=True, ) -async def submission_id_accept_policy_post( +async def accept_policy_post( submission_id: str = Path(..., description="Id of the submission to get."), agreement: AgreedToPolicy = Body(None, description=""), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> object: """Agree to an arXiv policy to initiate a new item submission or a change to an existing item. """ - return await implementation.submission_id_accept_policy_post(impl_dep, user, client, submission_id, agreement) + return await implementation.accept_policy_post(impl_dep, user, client, submission_id, agreement) @router.post( @@ -121,12 +109,12 @@ async def submission_id_accept_policy_post( tags=["post submit"], response_model_by_alias=True, ) -async def submission_id_deposited_post( +async def mark_deposited_post( submission_id: str = Path(..., description="Id of the submission to get."), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: """Mark that the submission has been successfully deposited into the arxiv corpus.""" - return await implementation.submission_id_deposited_post(impl_dep, user, client, submission_id) + return await implementation.mark_deposited_post(impl_dep, user, client, submission_id) @router.post( @@ -137,12 +125,12 @@ async def submission_id_deposited_post( tags=["post submit"], response_model_by_alias=True, ) -async def submission_id_mark_processing_for_deposit_post( +async def _mark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: """Mark that the submission is being processed for deposit.""" - return await implementation.submission_id_mark_processing_for_deposit_post(impl_dep, user, client, submission_id) + return await implementation.mark_processing_for_deposit_post(impl_dep, user, client, submission_id) @router.post( @@ -153,7 +141,7 @@ async def submission_id_mark_processing_for_deposit_post( tags=["post submit"], response_model_by_alias=True, ) -async def submission_id_unmark_processing_for_deposit_post( +async def unmark_processing_for_deposit_post( submission_id: str = Path(..., description="Id of the submission to get."), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep ) -> None: @@ -161,4 +149,17 @@ async def submission_id_unmark_processing_for_deposit_post( This just indicates that the submission is no longer in processing state. This does not indicate that it was successfully deposited. """ - return await implementation.submission_id_unmark_processing_for_deposit_post(impl_dep, user, client, submission_id) + return await implementation.unmark_processing_for_deposit_post(impl_dep, user, client, submission_id) + +@router.get( + "/status", + responses={ + 200: {"description": "system is working correctly"}, + 500: {"description": "system is not working correctly"}, + }, + tags=["service"], + response_model_by_alias=True, +) +async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: + """Get information about the current status of file management service.""" + return await implementation.get_service_status(impl_dep) diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py index 918be3c..ece73b5 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -32,7 +32,7 @@ async def start( ... @abstractmethod - async def submission_id_accept_policy_post( + async def accept_policy_post( self, impl_data: Dict, user: User, @@ -44,18 +44,18 @@ async def submission_id_accept_policy_post( ... @abstractmethod - async def submission_id_deposited_post( + async def mark_deposited_post( self, impl_data: Dict, user: User, client: Client, submission_id: str, ) -> None: - """The ui-app has been successfully deposited by an external service.""" + """The submission been successfully deposited into the arxiv corpus.""" ... @abstractmethod - async def submission_id_mark_processing_for_deposit_post( + async def mark_processing_for_deposit_post( self, impl_data: Dict, user: User, @@ -66,7 +66,7 @@ async def submission_id_mark_processing_for_deposit_post( ... @abstractmethod - async def submission_id_unmark_processing_for_deposit_post( + async def unmark_processing_for_deposit_post( self, impl_data: Dict, user: User, diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 220c962..d808af6 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -1,3 +1,4 @@ +import datetime import logging from typing import Dict, Union @@ -6,7 +7,7 @@ from arxiv.db.models import Submission, Document, configure_db_engine from fastapi import Depends, HTTPException, status from sqlalchemy import create_engine, select -from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession +from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession, Session from submit_ce.submit_fastapi.api.models.agent import User, Client from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi @@ -45,28 +46,109 @@ def get_session(): def legacy_depends(db=Depends(get_session)) -> dict: return {"session": db} +def check_user_authorized(session: Session, user: User, client: Client, submision_id: str) -> None: + pass # TODO implement authorized check, use scopes from arxiv.auth? + +def check_submission_exists(session: Session, submission_id: str) -> Submission: + try: + stmt = select(Submission).where(Submission.submission_id == int(submission_id)) + submission = session.scalars(stmt).first() + if not submission: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Submission {submission_id} does not exist") + else: + return submission + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Submission {submission_id} does not exist (legacy must use int ids)") + class LegacySubmitImplementation(BaseDefaultApi): async def get_submission(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> object: session = impl_data["session"] - stmt = select(Submission).where(Submission.submission_id==int(submission_id)) - submission = session.scalars(stmt).first() - if submission: - data = {c.name: getattr(submission, c.name) for c in Submission.__table__.columns} - return data - else: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + submission = check_submission_exists(session, submission_id) + return {c.name: getattr(submission, c.name) for c in Submission.__table__.columns} async def start(self, impl_data: Dict, user: User, client: Client, started: Union[StartedNew, StartedAlterExising]) -> str: session = impl_data["session"] - submission = Submission(stage=0, - submitter_id=user.identifier, + + """Example of a started legacy sub: + mysql> select * from arXiv_submissions where submission_id > 100333 and stage= 0 limit 1\G +*************************** 1. row *************************** + submission_id: 100345 + document_id: NULL + doc_paper_id: NULL + sword_id: NULL + userinfo: 0 + is_author: 0 + agree_policy: 0 + viewed: 0 + stage: 0 + submitter_id: 14416 + submitter_name: Bob Perox + submitter_email: bobp@example.com + created: 2010-08-31 10:30:18 + updated: 2011-02-10 09:38:38 + status: 30 + sticky_status: NULL + must_process: 1 + submit_time: NULL + release_time: NULL + source_size: 0 + source_format: NULL + source_flags: NULL + has_pilot_data: NULL + is_withdrawn: 0 + title: NULL + authors: NULL + comments: NULL + proxy: NULL + report_num: NULL + msc_class: NULL + acm_class: NULL + journal_ref: NULL + doi: NULL + abstract: NULL + license: NULL + version: 1 + type: new + is_ok: NULL + admin_ok: NULL + allow_tex_produced: 0 + is_oversize: 0 + remote_addr: 195.1.1.1. + remote_host: example.com + package: + rt_ticket_id: NULL + auto_hold: 0 + is_locked: 0 + agreement_id: NULL + data_version: 1 + metadata_version: 1 + data_needed: 0 + data_version_queued: 0 +metadata_version_queued: 0 + data_queued_time: NULL + metadata_queued_time: NULL +1 row in set (0.00 sec)""" + now = datetime.datetime.utcnow() + submission = Submission(submitter_id=user.identifier, submitter_name=user.get_name(), + userinfo=0, + agree_policy=0, + viewed=0, + stage=0, + created=now, + updated=now, + source_size=0, + allow_tex_produced=0, + is_oversize=0, + auto_hold=0, remote_addr=client.remoteAddress, remote_host=client.remoteHost, type=started.submission_type, - package="TODOwhatisthis" + package="", ) if isinstance(started, StartedAlterExising): doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() @@ -84,18 +166,20 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio - async def submission_id_accept_policy_post(self, impl_data: Dict, user: User, client: Client, - submission_id: str, - agreement: AgreedToPolicy) -> object: - pass + async def accept_policy_post(self, impl_data: Dict, user: User, client: Client, + submission_id: str, + agreement: AgreedToPolicy) -> object: + session = impl_data["session"] + submission = check_submission_exists(session, submission_id) + - async def submission_id_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: + async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass - async def submission_id_mark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: + async def mark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass - async def submission_id_unmark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: + async def unmark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass async def get_service_status(self, impl_data: dict): From db9ddbcab1f0b20d52a3d0cb46e24ccc018ce0aa Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Wed, 18 Sep 2024 16:54:08 -0400 Subject: [PATCH 19/28] Basic accept policy and test implemented --- .../api/models/events/__init__.py | 19 ++++- .../implementations/legacy_implementation.py | 71 +++---------------- tests/test_default_api.py | 71 ++++++++++++++----- 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/submit_ce/submit_fastapi/api/models/events/__init__.py b/submit_ce/submit_fastapi/api/models/events/__init__.py index 32da3fc..e6c0739 100644 --- a/submit_ce/submit_fastapi/api/models/events/__init__.py +++ b/submit_ce/submit_fastapi/api/models/events/__init__.py @@ -73,11 +73,26 @@ class EventInfo(BaseModel): """ -class AgreedToPolicy(BaseEvent): +class AgreedToPolicy(BaseModel): """ The sender of this request agrees to the statement in the agreement. """ - accepted_policy: StrictStr + accepted_policy_id: int + """The ID of the policy the sender agrees to.""" + + +class SetLicense(BaseModel): + """ + The sender of this request agrees to offer the submitted items under the statement in the license. + """ + agreement_id: Literal[ + "http://creativecommons.org/licenses/by/4.0/", + "http://creativecommons.org/licenses/by-sa/4.0/", + "http://creativecommons.org/licenses/by-nc-sa/4.0/", + "http://creativecommons.org/licenses/by-nc-nd/4.0/", + "http://arxiv.org/licenses/nonexclusive-distrib/1.0/", + "http://creativecommons.org/publicdomain/zero/1.0/", + ] class StartedNew(BaseModel): diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index d808af6..86d0691 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -72,66 +72,6 @@ async def get_submission(self, impl_data: Dict, user: User, client: Client, subm async def start(self, impl_data: Dict, user: User, client: Client, started: Union[StartedNew, StartedAlterExising]) -> str: session = impl_data["session"] - - """Example of a started legacy sub: - mysql> select * from arXiv_submissions where submission_id > 100333 and stage= 0 limit 1\G -*************************** 1. row *************************** - submission_id: 100345 - document_id: NULL - doc_paper_id: NULL - sword_id: NULL - userinfo: 0 - is_author: 0 - agree_policy: 0 - viewed: 0 - stage: 0 - submitter_id: 14416 - submitter_name: Bob Perox - submitter_email: bobp@example.com - created: 2010-08-31 10:30:18 - updated: 2011-02-10 09:38:38 - status: 30 - sticky_status: NULL - must_process: 1 - submit_time: NULL - release_time: NULL - source_size: 0 - source_format: NULL - source_flags: NULL - has_pilot_data: NULL - is_withdrawn: 0 - title: NULL - authors: NULL - comments: NULL - proxy: NULL - report_num: NULL - msc_class: NULL - acm_class: NULL - journal_ref: NULL - doi: NULL - abstract: NULL - license: NULL - version: 1 - type: new - is_ok: NULL - admin_ok: NULL - allow_tex_produced: 0 - is_oversize: 0 - remote_addr: 195.1.1.1. - remote_host: example.com - package: - rt_ticket_id: NULL - auto_hold: 0 - is_locked: 0 - agreement_id: NULL - data_version: 1 - metadata_version: 1 - data_needed: 0 - data_version_queued: 0 -metadata_version_queued: 0 - data_queued_time: NULL - metadata_queued_time: NULL -1 row in set (0.00 sec)""" now = datetime.datetime.utcnow() submission = Submission(submitter_id=user.identifier, submitter_name=user.get_name(), @@ -164,14 +104,19 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio session.commit() return str(submission.submission_id) - - async def accept_policy_post(self, impl_data: Dict, user: User, client: Client, submission_id: str, agreement: AgreedToPolicy) -> object: session = impl_data["session"] submission = check_submission_exists(session, submission_id) - + if agreement.accepted_policy_id != 3: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"policy {agreement.accepted_policy_id} is not the currently accepted policy.") + if submission.agree_policy == 1: + return + submission.agreement_id = agreement.accepted_policy_id + submission.agree_policy = 1 + session.commit() async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 20e649b..e8f7f37 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -48,24 +48,61 @@ def test_start(client: TestClient): def test_submission_id_accept_policy_post(client: TestClient): - """Test case for submission_id_accept_policy_post - - - """ - agreement = {"submission_id":"submission_id","agreement":"agreement","name":"name"} - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "POST", - # "/{submission_id}/acceptPolicy".format(submission_id='submission_id_example'), - # headers=headers, - # json=agreement, - #) + """Test case for submission_id_accept_policy_post.""" + headers = {} + response = client.request("POST", "/v1/start", headers=headers, + json={"submission_type": "new"}) + assert response.status_code == 200 + sid = response.text + assert sid is not None + response = client.request("GET", f"/v1/submission/{sid}", headers=headers) + assert response.status_code == 200 + data = response.json() + assert str(data['submission_id']) == sid + assert data['agreement_id'] != 3 + assert data['agree_policy'] == 0 + + response = client.request( + "POST", + f"/v1/submission/888888/acceptPolicy", + headers=headers, + json={"accepted_policy_id": 3}) + assert response.status_code == 404 + + response = client.request( + "POST", + f"/v1/submission/{sid}/acceptPolicy", + headers=headers, + json={"junk_data": 1}) + assert response.status_code >= 422 + + response = client.request( + "POST", + f"/v1/submission/{sid}/acceptPolicy", + headers=headers, + json={"accepted_policy_id": 1}) + assert response.status_code >= 400 + + response = client.request( + "POST", + f"/v1/submission/{sid}/acceptPolicy", + headers=headers, + json={"accepted_policy_id":3}) + assert response.status_code == 200 - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 + response = client.request("GET", f"/v1/submission/{sid}", headers=headers) + assert response.status_code == 200 + data = response.json() + assert str(data['submission_id']) == sid + assert data['agreement_id'] == 3 + assert data['agree_policy'] == 1 + + response = client.request( + "POST", + f"/v1/submission/{sid}/acceptPolicy", + headers=headers, + json={"accepted_policy_id":3}) + assert response.status_code == 200 def test_submission_id_deposit_packet_packet_format_get(client: TestClient): From bbc2e9e335352c6cf4a790dcfe3d0f2e2fe31b06 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Thu, 19 Sep 2024 08:24:08 -0400 Subject: [PATCH 20/28] Basic set license with current accepted licenses --- submit_ce/submit_fastapi/api/default_api.py | 43 +++++++++++++-- .../submit_fastapi/api/default_api_base.py | 16 +++++- .../api/models/events/__init__.py | 20 ++++++- .../implementations/legacy_implementation.py | 25 ++++++++- tests/test_default_api.py | 53 ++++++++++++++++++- 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/submit_fastapi/api/default_api.py index a981f69..87fe003 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/submit_fastapi/api/default_api.py @@ -17,12 +17,12 @@ status, ) from fastapi.responses import PlainTextResponse -from pydantic import BaseModel, Field from submit_ce.submit_fastapi.config import config from .default_api_base import BaseDefaultApi -from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising +from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, AuthorshipDirect, \ + AuthorshipProxy from ..auth import get_user, get_client from ..implementations import ImplementationConfig @@ -89,7 +89,6 @@ async def get_submission( 500: {"description": "Error. There was a problem. The agreement was not accepted."}, }, tags=["submit"], - response_model_by_alias=True, ) async def accept_policy_post( submission_id: str = Path(..., description="Id of the submission to get."), @@ -101,6 +100,42 @@ async def accept_policy_post( return await implementation.accept_policy_post(impl_dep, user, client, submission_id, agreement) +@router.post( + "/submission/{submission_id}/setLicense", + tags=["submit"], +) +async def set_license_post( + submission_id: str = Path(..., description="Id of the submission to set the license for."), + license: SetLicense = Body(None, description="The license to set"), + impl_dep: dict = Depends(impl_depends), + user=userDep, client=clentDep +) -> None: + """Set a license for a files of a submission.""" + return await implementation.set_license_post(impl_dep, user, client, submission_id, license) + + +@router.post( + "/submission/{submission_id}/assertAuthorship", + tags=["submit"], +) +async def assert_authorship_post( + submission_id: str = Path(..., description="Id of the submission to assert authorship for."), + authorship: Union[AuthorshipDirect, AuthorshipProxy] = Body(None, description=""), + impl_dep: dict = Depends(impl_depends), + user=userDep, client=clentDep +) -> str: + return await implementation.assert_authorship_post(impl_dep, user, client, submission_id, authorship) + + +# todo +""" +/files get post head delete +/files/{path} get post head delete + +preview post get head delete + + +""" @router.post( "/submission/{submission_id}/markDeposited", responses={ @@ -160,6 +195,6 @@ async def unmark_processing_for_deposit_post( tags=["service"], response_model_by_alias=True, ) -async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> None: +async def get_service_status(impl_dep: dict = Depends(impl_depends)) -> str: """Get information about the current status of file management service.""" return await implementation.get_service_status(impl_dep) diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/submit_fastapi/api/default_api_base.py index ece73b5..6cfeda3 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/submit_fastapi/api/default_api_base.py @@ -3,7 +3,8 @@ from typing import ClassVar, Dict, List, Tuple, Union # noqa: F401 -from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew +from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ + SetLicense from submit_ce.submit_fastapi.api.models.agent import User, Client @@ -83,3 +84,16 @@ async def get_service_status( ) -> Tuple[bool, str]: """Service health.""" ... + + @abstractmethod + async def set_license_post(self, impl_dep: Dict, user: User, client: Client, + submission_id: str, license: SetLicense) -> None: + """Sets the license of the submission files.""" + ... + + async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Client, + submission_id: str, authorship: Union[AuthorshipDirect, AuthorshipProxy]) -> str: + """Assert authorship of the submission files. + + Or assert that the submitter has authority to submit the files as a proxy.""" + ... diff --git a/submit_ce/submit_fastapi/api/models/events/__init__.py b/submit_ce/submit_fastapi/api/models/events/__init__.py index e6c0739..5605e3d 100644 --- a/submit_ce/submit_fastapi/api/models/events/__init__.py +++ b/submit_ce/submit_fastapi/api/models/events/__init__.py @@ -85,7 +85,7 @@ class SetLicense(BaseModel): """ The sender of this request agrees to offer the submitted items under the statement in the license. """ - agreement_id: Literal[ + license_uri: Literal[ "http://creativecommons.org/licenses/by/4.0/", "http://creativecommons.org/licenses/by-sa/4.0/", "http://creativecommons.org/licenses/by-nc-sa/4.0/", @@ -93,6 +93,24 @@ class SetLicense(BaseModel): "http://arxiv.org/licenses/nonexclusive-distrib/1.0/", "http://creativecommons.org/publicdomain/zero/1.0/", ] + """The license the sender offers to the arxiv users for the submitted items.""" + + +class AuthorshipDirect(BaseModel): + """ + Asserts the sender of this request is the author of the submitted items. + """ + i_am_author: bool + """By sending `True` the sender asserts they are the author of the submitted items.""" + +class AuthorshipProxy(BaseModel): + """ + Asserts that the sender of this request is authorized to deposit the submitted items by the author of the items. + """ + i_am_authorized_to_proxy: bool + """By sending `True` the sender asserts they are the authorized proxy of the author of the submitted items.""" + proxy: str + """Email address of the author of the submitted items.""" class StartedNew(BaseModel): diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/submit_fastapi/implementations/legacy_implementation.py index 86d0691..cc88538 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/submit_fastapi/implementations/legacy_implementation.py @@ -11,7 +11,8 @@ from submit_ce.submit_fastapi.api.models.agent import User, Client from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi -from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising +from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ + AuthorshipDirect, AuthorshipProxy from submit_ce.submit_fastapi.config import Settings from submit_ce.submit_fastapi.implementations import ImplementationConfig @@ -65,6 +66,7 @@ def check_submission_exists(session: Session, submission_id: str) -> Submission: class LegacySubmitImplementation(BaseDefaultApi): + async def get_submission(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> object: session = impl_data["session"] submission = check_submission_exists(session, submission_id) @@ -118,6 +120,27 @@ async def accept_policy_post(self, impl_data: Dict, user: User, client: Client, submission.agree_policy = 1 session.commit() + async def set_license_post(self, impl_dep: dict, user: User, client: Client, + submission_id: str, set_license: SetLicense) -> None: + session = impl_dep["session"] + check_user_authorized(session, user, client, submission_id) + submission = check_submission_exists(session, submission_id) + submission.license = set_license.license_uri + session.commit() + + async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Client, + submission_id: str, authorship: Union[AuthorshipDirect, AuthorshipProxy]) -> str: + session = impl_dep["session"] + check_user_authorized(session, user, client, submission_id) + submission = check_submission_exists(session, submission_id) + if isinstance(authorship, AuthorshipDirect): + submission.is_author=1 + else: + submission.is_author=0 + submission.proxy=authorship.proxy + session.commit() + + async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass diff --git a/tests/test_default_api.py b/tests/test_default_api.py index e8f7f37..c92e72e 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -1,5 +1,5 @@ # coding: utf-8 - +import pytest from fastapi.testclient import TestClient from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy @@ -104,6 +104,57 @@ def test_submission_id_accept_policy_post(client: TestClient): json={"accepted_policy_id":3}) assert response.status_code == 200 +def test_license(client: TestClient): + headers = {} + response = client.request("POST", "/v1/start", headers=headers, + json={"submission_type": "new"}) + assert response.status_code == 200 + sid = response.text + assert sid is not None + response = client.request("GET", f"/v1/submission/{sid}", headers=headers) + assert response.status_code == 200 and not response.json()['license'] + + response = client.request("POST", f"/v1/submission/{sid}/setLicense", + json={"license_uri":"http://arxiv.org/licenses/nonexclusive-distrib/1.0/"}, + headers=headers) + assert response.status_code == 200 + + response = client.request("GET", f"/v1/submission/{sid}", headers=headers) + assert (response.status_code == 200 + and response.json()['license'] == "http://arxiv.org/licenses/nonexclusive-distrib/1.0/") + + response = client.request("POST", f"/v1/submission/{sid}/setLicense", + json={"license_uri":"bogus_license"}, + headers=headers) + assert response.status_code == 422 + + no_longer_valid = "http://arxiv.org/licenses/assumed-1991-2003/" + response = client.request("POST", f"/v1/submission/{sid}/setLicense", + json={"license_uri":no_longer_valid}, headers=headers) + assert response.status_code == 422 + +@pytest.mark.parametrize("invalid_license",[ + "http://arxiv.org/licenses/assumed-1991-2003/", + "http://creativecommons.org/licenses/by/3.0/", + "http://creativecommons.org/licenses/by-nc-sa/3.0/", + "http://creativecommons.org/licenses/publicdomain/" + "http://creativecommons.org/licenses/fake_not_reall/", + "", + "totally_fake_not_reall", +]) +def test_invalid_license(client: TestClient, invalid_license:str): + headers = {} + response = client.request("POST", "/v1/start", headers=headers, + json={"submission_type": "new"}) + assert response.status_code == 200 + sid = response.text + assert sid is not None + + no_longer_valid = "http://arxiv.org/licenses/assumed-1991-2003/" + response = client.request("POST", f"/v1/submission/{sid}/setLicense", + json={"license_uri": invalid_license}, headers=headers) + assert response.status_code == 422 + def test_submission_id_deposit_packet_packet_format_get(client: TestClient): """Test case for submission_id_deposit_packet_packet_format_get From d3cdf6aa8340c04bd714fcb5cf2d39e625a05f76 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Thu, 19 Sep 2024 08:49:20 -0400 Subject: [PATCH 21/28] Adds python-mutlipart to pyproject --- README.md | 2 +- poetry.lock | 2 +- pyproject.toml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f8ba41..c8f1800 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ poetry install # TODO How to get arxiv-base? poetry shell # or however you do venvs python test/make_test_db.py # to make sqlite dev db -fastapi dev src/arxiv/submit_fastapi/main.py +fastapi dev main.py ``` and open your browser at `http://localhost:8000/docs/` to see the docs. diff --git a/poetry.lock b/poetry.lock index 0457a00..77838b1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2441,4 +2441,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "ee2841f61aa4fe4a930074db3a1ea200d42b31813f4fef2365baafa21722377f" +content-hash = "9dd88de47ddc807b00d5c9bd21c5b357c45431a5176fd3b2a72e6a059f5f45ae" diff --git a/pyproject.toml b/pyproject.toml index 7e25e92..7a3c6ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ pydantic-settings = "^2.5.2" fastapi = {extras = ["all"], version = "^0.114.2"} sqlalchemy = "^2.0.34" fire = "^0.6.0" +python-multipart = "^0.0.9" [tool.poetry.group.dev.dependencies] coverage = "*" From 09ad43682cf268442cdd97d0a961491b3383cf22 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Thu, 19 Sep 2024 08:51:22 -0400 Subject: [PATCH 22/28] moves submit_ce.submit_fastapi to submit_ce.fastapi --- main.py | 2 +- .../{submit_fastapi => fastapi}/__init__.py | 0 .../api/__init__.py | 0 .../api/default_api.py | 19 +++++++++++++++++-- .../api/default_api_base.py | 13 +++++++++++-- .../api/models/__init__.py | 0 .../api/models/agent.py | 0 .../api/models/events/__init__.py | 2 +- .../api/models/extra_models.py | 0 submit_ce/{submit_fastapi => fastapi}/app.py | 2 +- submit_ce/{submit_fastapi => fastapi}/auth.py | 2 +- .../{submit_fastapi => fastapi}/config.py | 2 +- .../implementations/__init__.py | 2 +- .../implementations/legacy_implementation.py | 14 ++++++++------ submit_ce/submit_fastapi/main.py | 13 ------------- tests/conftest.py | 4 ++-- tests/make_test_db.py | 2 +- tests/test_default_api.py | 2 +- 18 files changed, 46 insertions(+), 33 deletions(-) rename submit_ce/{submit_fastapi => fastapi}/__init__.py (100%) rename submit_ce/{submit_fastapi => fastapi}/api/__init__.py (100%) rename submit_ce/{submit_fastapi => fastapi}/api/default_api.py (92%) rename submit_ce/{submit_fastapi => fastapi}/api/default_api_base.py (85%) rename submit_ce/{submit_fastapi => fastapi}/api/models/__init__.py (100%) rename submit_ce/{submit_fastapi => fastapi}/api/models/agent.py (100%) rename submit_ce/{submit_fastapi => fastapi}/api/models/events/__init__.py (98%) rename submit_ce/{submit_fastapi => fastapi}/api/models/extra_models.py (100%) rename submit_ce/{submit_fastapi => fastapi}/app.py (86%) rename submit_ce/{submit_fastapi => fastapi}/auth.py (93%) rename submit_ce/{submit_fastapi => fastapi}/config.py (78%) rename submit_ce/{submit_fastapi => fastapi}/implementations/__init__.py (76%) rename submit_ce/{submit_fastapi => fastapi}/implementations/legacy_implementation.py (92%) delete mode 100644 submit_ce/submit_fastapi/main.py diff --git a/main.py b/main.py index 266b8c5..577e124 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ if __name__ == "__main__": import uvicorn - uvicorn.run("submit_ce.submit_fastapi.app:app", host="127.0.0.1", port=8000, reload=True) + uvicorn.run("submit_ce.fastapi.app:app", host="127.0.0.1", port=8000, reload=True) diff --git a/submit_ce/submit_fastapi/__init__.py b/submit_ce/fastapi/__init__.py similarity index 100% rename from submit_ce/submit_fastapi/__init__.py rename to submit_ce/fastapi/__init__.py diff --git a/submit_ce/submit_fastapi/api/__init__.py b/submit_ce/fastapi/api/__init__.py similarity index 100% rename from submit_ce/submit_fastapi/api/__init__.py rename to submit_ce/fastapi/api/__init__.py diff --git a/submit_ce/submit_fastapi/api/default_api.py b/submit_ce/fastapi/api/default_api.py similarity index 92% rename from submit_ce/submit_fastapi/api/default_api.py rename to submit_ce/fastapi/api/default_api.py index 87fe003..60b3372 100644 --- a/submit_ce/submit_fastapi/api/default_api.py +++ b/submit_ce/fastapi/api/default_api.py @@ -14,11 +14,11 @@ Query, Response, Security, - status, + status, UploadFile, ) from fastapi.responses import PlainTextResponse -from submit_ce.submit_fastapi.config import config +from submit_ce.fastapi.config import config from .default_api_base import BaseDefaultApi from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, AuthorshipDirect, \ @@ -126,6 +126,21 @@ async def assert_authorship_post( ) -> str: return await implementation.assert_authorship_post(impl_dep, user, client, submission_id, authorship) +@router.post( + "/submission/{submission_id}/files", + tags=["submit"], +) +async def file_post( + uploadFile: UploadFile, # waring: this uses https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile + impl_dep: dict = Depends(impl_depends), + user=userDep, client=clentDep +): + """Upload a file to a submission. + + The file can be a single file, a zip, or a tar.gz. Zip and tar.gz files will be unpacked. + """ + return await implementation.file_post(impl_dep, user, client, uploadFile) + # todo """ diff --git a/submit_ce/submit_fastapi/api/default_api_base.py b/submit_ce/fastapi/api/default_api_base.py similarity index 85% rename from submit_ce/submit_fastapi/api/default_api_base.py rename to submit_ce/fastapi/api/default_api_base.py index 6cfeda3..13aac46 100644 --- a/submit_ce/submit_fastapi/api/default_api_base.py +++ b/submit_ce/fastapi/api/default_api_base.py @@ -3,9 +3,11 @@ from typing import ClassVar, Dict, List, Tuple, Union # noqa: F401 -from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ +from fastapi import UploadFile + +from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ SetLicense -from submit_ce.submit_fastapi.api.models.agent import User, Client +from submit_ce.fastapi.api.models.agent import User, Client class BaseDefaultApi(ABC): @@ -97,3 +99,10 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien Or assert that the submitter has authority to submit the files as a proxy.""" ... + + async def file_post(self, impl_dep: Dict, user: User, client: Client, uploadFile: UploadFile): + """Upload a file to a submission. + + The file can be a single file, a zip, or a tar.gz. Zip and tar.gz files will be unpacked. + """ + ... diff --git a/submit_ce/submit_fastapi/api/models/__init__.py b/submit_ce/fastapi/api/models/__init__.py similarity index 100% rename from submit_ce/submit_fastapi/api/models/__init__.py rename to submit_ce/fastapi/api/models/__init__.py diff --git a/submit_ce/submit_fastapi/api/models/agent.py b/submit_ce/fastapi/api/models/agent.py similarity index 100% rename from submit_ce/submit_fastapi/api/models/agent.py rename to submit_ce/fastapi/api/models/agent.py diff --git a/submit_ce/submit_fastapi/api/models/events/__init__.py b/submit_ce/fastapi/api/models/events/__init__.py similarity index 98% rename from submit_ce/submit_fastapi/api/models/events/__init__.py rename to submit_ce/fastapi/api/models/events/__init__.py index 5605e3d..4811ced 100644 --- a/submit_ce/submit_fastapi/api/models/events/__init__.py +++ b/submit_ce/fastapi/api/models/events/__init__.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, AwareDatetime, StrictStr -from submit_ce.submit_fastapi.api.models.agent import User, Client +from submit_ce.fastapi.api.models.agent import User, Client class BaseEvent(BaseModel): diff --git a/submit_ce/submit_fastapi/api/models/extra_models.py b/submit_ce/fastapi/api/models/extra_models.py similarity index 100% rename from submit_ce/submit_fastapi/api/models/extra_models.py rename to submit_ce/fastapi/api/models/extra_models.py diff --git a/submit_ce/submit_fastapi/app.py b/submit_ce/fastapi/app.py similarity index 86% rename from submit_ce/submit_fastapi/app.py rename to submit_ce/fastapi/app.py index ab066e4..27505c9 100644 --- a/submit_ce/submit_fastapi/app.py +++ b/submit_ce/fastapi/app.py @@ -1,7 +1,7 @@ import os from fastapi import FastAPI -from submit_ce.submit_fastapi.api.default_api import router as DefaultApiRouter +from submit_ce.fastapi.api.default_api import router as DefaultApiRouter from .config import config, DEV_SQLITE_FILE from arxiv.config import settings diff --git a/submit_ce/submit_fastapi/auth.py b/submit_ce/fastapi/auth.py similarity index 93% rename from submit_ce/submit_fastapi/auth.py rename to submit_ce/fastapi/auth.py index ad02b66..167fd25 100644 --- a/submit_ce/submit_fastapi/auth.py +++ b/submit_ce/fastapi/auth.py @@ -4,7 +4,7 @@ from fastapi import HTTPException, status, Request -from submit_ce.submit_fastapi.api.models.agent import User, Client +from submit_ce.fastapi.api.models.agent import User, Client async def get_client(request: Request) -> Client: diff --git a/submit_ce/submit_fastapi/config.py b/submit_ce/fastapi/config.py similarity index 78% rename from submit_ce/submit_fastapi/config.py rename to submit_ce/fastapi/config.py index 2534f63..87daf7e 100644 --- a/submit_ce/submit_fastapi/config.py +++ b/submit_ce/fastapi/config.py @@ -10,7 +10,7 @@ class Settings(BaseSettings): """CLASSIC_DB_URI and other configs are from arxiv-base arxiv.config.""" - submission_api_implementation: ImportString = 'submit_ce.submit_fastapi.implementations.legacy_implementation.implementation' + submission_api_implementation: ImportString = 'submit_ce.fastapi.implementations.legacy_implementation.implementation' """Class to use for submission API implementation.""" diff --git a/submit_ce/submit_fastapi/implementations/__init__.py b/submit_ce/fastapi/implementations/__init__.py similarity index 76% rename from submit_ce/submit_fastapi/implementations/__init__.py rename to submit_ce/fastapi/implementations/__init__.py index ea2acae..77957d1 100644 --- a/submit_ce/submit_fastapi/implementations/__init__.py +++ b/submit_ce/fastapi/implementations/__init__.py @@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings -from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi +from submit_ce.fastapi.api.default_api_base import BaseDefaultApi @dataclass diff --git a/submit_ce/submit_fastapi/implementations/legacy_implementation.py b/submit_ce/fastapi/implementations/legacy_implementation.py similarity index 92% rename from submit_ce/submit_fastapi/implementations/legacy_implementation.py rename to submit_ce/fastapi/implementations/legacy_implementation.py index cc88538..eb8ffa9 100644 --- a/submit_ce/submit_fastapi/implementations/legacy_implementation.py +++ b/submit_ce/fastapi/implementations/legacy_implementation.py @@ -5,16 +5,16 @@ from arxiv.config import settings import arxiv.db from arxiv.db.models import Submission, Document, configure_db_engine -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, UploadFile from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession, Session -from submit_ce.submit_fastapi.api.models.agent import User, Client -from submit_ce.submit_fastapi.api.default_api_base import BaseDefaultApi -from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ +from submit_ce.fastapi.api.models.agent import User, Client +from submit_ce.fastapi.api.default_api_base import BaseDefaultApi +from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ AuthorshipDirect, AuthorshipProxy -from submit_ce.submit_fastapi.config import Settings -from submit_ce.submit_fastapi.implementations import ImplementationConfig +from submit_ce.fastapi.config import Settings +from submit_ce.fastapi.implementations import ImplementationConfig logger = logging.getLogger(__name__) @@ -140,6 +140,8 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien submission.proxy=authorship.proxy session.commit() + async def file_post(self, impl_dep: Dict, user: User, client: Client, uploadFile: UploadFile): + return async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass diff --git a/submit_ce/submit_fastapi/main.py b/submit_ce/submit_fastapi/main.py deleted file mode 100644 index 068221f..0000000 --- a/submit_ce/submit_fastapi/main.py +++ /dev/null @@ -1,13 +0,0 @@ -# coding: utf-8 - -""" - arxiv submit - - No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - - The version of the OpenAPI document: 0.1 - Contact: nextgen@arxiv.org - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 diff --git a/tests/conftest.py b/tests/conftest.py index 4a41c12..89d96a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from submit_ce.submit_fastapi.config import DEV_SQLITE_FILE +from submit_ce.fastapi.config import DEV_SQLITE_FILE from tests.make_test_db import create_all_legacy_db @@ -29,7 +29,7 @@ def app(legacy_db) -> FastAPI: settings.CLASSIC_DB_URI = url # Don't import until now so settings can be altered - from submit_ce.submit_fastapi.app import app as application + from submit_ce.fastapi.app import app as application application.dependency_overrides = {} return application diff --git a/tests/make_test_db.py b/tests/make_test_db.py index 2bf1ba4..05f7b73 100644 --- a/tests/make_test_db.py +++ b/tests/make_test_db.py @@ -6,7 +6,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session import fire -from submit_ce.submit_fastapi.config import DEV_SQLITE_FILE +from submit_ce.fastapi.config import DEV_SQLITE_FILE def create_all_legacy_db(test_db_file: str=DEV_SQLITE_FILE, echo: bool=False): diff --git a/tests/test_default_api.py b/tests/test_default_api.py index c92e72e..6e541ca 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -2,7 +2,7 @@ import pytest from fastapi.testclient import TestClient -from submit_ce.submit_fastapi.api.models.events import AgreedToPolicy +from submit_ce.fastapi.api.models.events import AgreedToPolicy def test_get_service_status(client: TestClient): From fe5a530f822621376dccc36a5bb31292c14ba16f Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Thu, 19 Sep 2024 12:14:43 -0400 Subject: [PATCH 23/28] basic working file upload of tar.gz --- .gitignore | 6 +- submit_ce/fastapi/api/default_api.py | 5 +- submit_ce/fastapi/api/default_api_base.py | 2 +- .../implementations/legacy_implementation.py | 59 ++++- submit_ce/file_store/__init__.py | 128 +++++++++++ submit_ce/file_store/legacy_file_store.py | 211 ++++++++++++++++++ 6 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 submit_ce/file_store/__init__.py create mode 100644 submit_ce/file_store/legacy_file_store.py diff --git a/.gitignore b/.gitignore index c281470..c6068b5 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,8 @@ cython_debug/ # morbund NG arxiv code graveyard/ -legacy.db \ No newline at end of file +# dev database +legacy.db + +# dev data new +/data \ No newline at end of file diff --git a/submit_ce/fastapi/api/default_api.py b/submit_ce/fastapi/api/default_api.py index 60b3372..c1decb1 100644 --- a/submit_ce/fastapi/api/default_api.py +++ b/submit_ce/fastapi/api/default_api.py @@ -132,14 +132,15 @@ async def assert_authorship_post( ) async def file_post( uploadFile: UploadFile, # waring: this uses https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile + submission_id: str = Path(..., description="Id of the submission to add the upload to."), impl_dep: dict = Depends(impl_depends), user=userDep, client=clentDep -): +)->str: """Upload a file to a submission. The file can be a single file, a zip, or a tar.gz. Zip and tar.gz files will be unpacked. """ - return await implementation.file_post(impl_dep, user, client, uploadFile) + return await implementation.file_post(impl_dep, user, client, submission_id, uploadFile) # todo diff --git a/submit_ce/fastapi/api/default_api_base.py b/submit_ce/fastapi/api/default_api_base.py index 13aac46..8e36c23 100644 --- a/submit_ce/fastapi/api/default_api_base.py +++ b/submit_ce/fastapi/api/default_api_base.py @@ -100,7 +100,7 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien Or assert that the submitter has authority to submit the files as a proxy.""" ... - async def file_post(self, impl_dep: Dict, user: User, client: Client, uploadFile: UploadFile): + async def file_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, uploadFile: UploadFile): """Upload a file to a submission. The file can be a single file, a zip, or a tar.gz. Zip and tar.gz files will be unpacked. diff --git a/submit_ce/fastapi/implementations/legacy_implementation.py b/submit_ce/fastapi/implementations/legacy_implementation.py index eb8ffa9..6a3df93 100644 --- a/submit_ce/fastapi/implementations/legacy_implementation.py +++ b/submit_ce/fastapi/implementations/legacy_implementation.py @@ -1,11 +1,13 @@ import datetime import logging -from typing import Dict, Union +from typing import Dict, Union, Optional from arxiv.config import settings import arxiv.db from arxiv.db.models import Submission, Document, configure_db_engine from fastapi import Depends, HTTPException, status, UploadFile +from pydantic import ImportString +from pydantic_settings import BaseSettings from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession, Session @@ -15,12 +17,36 @@ AuthorshipDirect, AuthorshipProxy from submit_ce.fastapi.config import Settings from submit_ce.fastapi.implementations import ImplementationConfig +from submit_ce.file_store import SubmissionFileStore +from submit_ce.file_store.legacy_file_store import LegacyFileStore logger = logging.getLogger(__name__) _setup = False -def get_session(): +class LegacySpecificSettings(BaseSettings): + legacy_data_new_prefix: str = "/data/new" + """Where to store the files. Ex. /data/new""" + + legacy_serialize_file_operations: bool = True + """Whether to lock on submission table row to serialize file write operations. + + Not serializing will expose the system to race conditions between different clients writing to the files. + + This will only prevent race conditions between other systems that use the row lock to exclusively write the files. + + Serializing will increase lock contention. """ + + legacy_root_dir: str = "data/new" + + + +legacy_specific_settings = LegacySpecificSettings(_case_sensitive=False) + +def db_lock_capable(session: SqlalchemySession) -> bool: + return "sqlite" not in session.get_bind().url + +def get_session() -> SqlalchemySession: """Dependency for fastapi routes""" global _setup if not _setup: @@ -65,7 +91,12 @@ def check_submission_exists(session: Session, submission_id: str) -> Submission: class LegacySubmitImplementation(BaseDefaultApi): - + def __init__(self, store: Optional[SubmissionFileStore] = None): + if store is None: + #self.store = LegacyFileStore(root_dir=legacy_specific_settings.legacy_root_dir) + self.store = LegacyFileStore(root_dir="data/new") # for testing only + else: + self.store = store async def get_submission(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> object: session = impl_data["session"] @@ -140,8 +171,26 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien submission.proxy=authorship.proxy session.commit() - async def file_post(self, impl_dep: Dict, user: User, client: Client, uploadFile: UploadFile): - return + async def file_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, uploadFile: UploadFile): + session: SqlalchemySession = impl_dep["session"] + check_user_authorized(session, user, client, submission_id) + if legacy_specific_settings.legacy_serialize_file_operations and db_lock_capable(session): + session.begin() + lock_stmt = select(Submission).where(Submission.submission_id == int(submission_id)).with_for_update() + submission = session.scalar(lock_stmt) + if not submission: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Submission {submission_id} does not exist") + else: + submission = check_submission_exists(session, submission_id) + acceptable_types = ["application/gzip", "application/tar", "application/tar+gzip"] + if uploadFile.content_type in acceptable_types: + checksum = await self.store.store_source_package(submission.submission_id, uploadFile) + + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="File content type must be one of {acceptable_types}"\ + " but it was {uploadFile.content_type}." + ) async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass diff --git a/submit_ce/file_store/__init__.py b/submit_ce/file_store/__init__.py new file mode 100644 index 0000000..368b142 --- /dev/null +++ b/submit_ce/file_store/__init__.py @@ -0,0 +1,128 @@ +import os +from abc import ABCMeta, abstractmethod +from pathlib import Path +from typing import IO + + +class SubmissionFileStore(metaclass=ABCMeta): + + @abstractmethod + def get_source_file(self, submission_id: str, path: Path) : + """Retrieve a file from the filesystem. + + path should be one of: + - a pathless file: main.tex + - a file inside src: figures/fig1.jpg + """ + ... + + + @abstractmethod + def store_source_package(self, submission_id: str, content, chunk_size) -> str: + """Store a source package for a submission. + + Returns checksum""" + pass + + @abstractmethod + def get_source_pacakge_checksum(self, submission_id: str) -> str: + """Get the checksum of the source package for a submission.""" + pass + + @abstractmethod + def does_source_exist(self, submission_id: str) -> bool: + """Determine whether source has been deposited for a submission.""" + pass + + + + @abstractmethod + def store_preview(self, submission_id: str, content: IO[bytes]) -> str: + """Store a preview PDF for a submission. + + Returns checksum""" + pass + + @abstractmethod + def get_preview(self, submission_id: str, path: Path): + """Retrieve a file from the filesystem. + + path should be one of: + - a pathless file: main.tex + - a file inside src: figures/fig1.jpg + """ + ... + + @abstractmethod + def get_preview_checksum(self, submission_id: str) -> str: + """Get the checksum of the preview PDF for a submission.""" + pass + + @abstractmethod + def does_preview_exist(self, submission_id: str) -> bool: + """Determine whether a preview has been deposited for a submission.""" + pass + + # @abstractmethod + # def _validate_submission_id(self, submission_id: str) -> bool: + # """Just because we have a type check here does not mean that it is impossible + # for `submission_id` to be something other than an `int`. Since I'm + # paranoid, we'll do a final check here to eliminate the possibility that a + # (potentially dangerous) ``str``-like value sneaks by.""" + # pass + # + # @abstractmethod + # def _submission_path(self, submission_id: str) -> Path: + # """Gets classic filesystem structure is such as /{rootdir}/{first 4 digits of submission id}/{submission id}""" + # pass + # + # @abstractmethod + # def _source_path(self, submission_id: str) -> Path: + # """Get the source path for the submission_id""" + # pass + # + # @abstractmethod + # def _source_package_path(self, submission_id: str) -> Path: + # pass + # + # @abstractmethod + # def _preview_path(self, submission_id: str) -> Path: + # pass + # + # @abstractmethod + # def _get_checksum(self, path) -> str: + # pass + # + # @abstractmethod + # def _unpack_tarfile(self, tar_path, unpack_to) -> None: + # pass + # + # @abstractmethod + # def _chmod_recurse(self, parent, dir_mode, file_mode, uid, gid) -> None: + # """ + # Recursively chmod and chown all directories and files. + # + # Parameters + # ---------- + # parent : str + # Root directory for the operation (included). + # dir_mode : int + # Mode to set directories. + # file_mode : int + # Mode to set files. + # uid : int + # UID for chown. + # gid : int + # GID for chown. + # + # """ + # pass + # + # @abstractmethod + # def _set_modes(self, path) -> None: + # pass + + @abstractmethod + def is_available(self) -> bool: + """Determine whether the filesystem is available.""" + pass diff --git a/submit_ce/file_store/legacy_file_store.py b/submit_ce/file_store/legacy_file_store.py new file mode 100644 index 0000000..07fff0e --- /dev/null +++ b/submit_ce/file_store/legacy_file_store.py @@ -0,0 +1,211 @@ +import os +from pathlib import Path +from typing import IO +from subprocess import Popen +from hashlib import md5 +from base64 import urlsafe_b64encode + +from submit_ce.file_store import SubmissionFileStore + + +class SecurityError(RuntimeError): + """Something suspicious happened.""" + +class LegacyFileStore(SubmissionFileStore): + """ + Functions for storing and getting source files from the legacy /data/new filesystem. + + In the legacy system we use a shared volume. Inside of that, the first four digits of the ID we'll call a + "shard id". The shard id is used to create a directory that in turn holds a directory for each id's files. + + For example, id ``65393829`` would have a directory at + ``{LEGACY_FILESYSTEM_ROOT}/6539/65393829``. + + The directory contains: + - a PDF that was compiled, + - a ``source.log`` file (for the admins to look at) + - a ``src`` directory that contains the actual file content. + + We also require the ability to set permissions on files and directories, and + set the owner user and group. + + To use this, the following config parameters must be set: + + - ``LEGACY_FILESYSTEM_ROOT``: (see above) + - ``LEGACY_FILESYSTEM_SOURCE_DIR_MODE``: permissions for directories; see + :ref:`python:os.chmod` + - ``LEGACY_FILESYSTEM_SOURCE_MODE``: permissions for files; see + :ref:`python:os.chmod` + - ``LEGACY_FILESYSTEM_SOURCE_UID``: uid for owner user (must exist) + - ``LEGACY_FILESYSTEM_SOURCE_GID``: gid for owner group (must exist) + - ``LEGACY_FILESYSTEM_SOURCE_PREFIX`` + + Adapted from NG arxiv-submission-core 2024-09-19. Changed to a class, use of Pathlib. + """ + + def __init__(self, + root_dir: Path, + source_file_mode = 0o42775, + source_dir_mode = 0o42775, + source_uid = os.geteuid(), + source_gid = os.getegid(), + source_prefix = "src" + ): + self.root_dir = root_dir + """Path to the root directory of the file store shards.""" + self.source_file_mode = source_file_mode + """Permission mode for files.""" + self.source_dir_mode = source_dir_mode + """Permission mode for directories.""" + self.source_uid = source_uid + """uid for owner user (must exist).""" + self.source_gid = source_gid + """gid for owner group (must exist).""" + self.source_prefix = source_prefix + """Prefix in the {root}/{shard}/{id} directory to store the source.""" + + def get_source_file(self, submission_id: str, path: Path): + pass + + def get_source_pacakge_checksum(self, submission_id: str) -> str: + pass + + def get_preview(self, submission_id: str, path: Path): + pass + + def is_available(self) -> bool: + """Determine whether the filesystem is available.""" + return os.path.exists(self.root_dir) + + async def store_source_package(self, + submission_id: int, + content: IO[bytes], + chunk_size: int = 4096) -> str: + """Store a source package for a submission.""" + # Make sure that we have a place to put the source files. + package_path = self._source_package_path(submission_id) + source_path = self._source_path(submission_id) + if not os.path.exists(package_path): + os.makedirs(os.path.split(package_path)[0]) + if not os.path.exists(source_path): + os.makedirs(source_path) + + with open(package_path, 'wb') as f: + while True: + chunk = await content.read(chunk_size) + if not chunk: + break + f.write(chunk) + + self._unpack_tarfile(package_path, source_path) + self._set_modes(package_path) + self._set_modes(source_path) + return self.get_source_checksum(submission_id) + + def store_preview(self, submission_id: int, content: IO[bytes], + chunk_size: int = 4096) -> str: + """Store a preview PDF for a submission.""" + preview_path = self._preview_path(submission_id) + if not os.path.exists(preview_path): + os.makedirs(os.path.split(preview_path)[0]) + with open(preview_path, 'wb') as f: + while True: + chunk = content.read(chunk_size) + if not chunk: + break + f.write(chunk) + self._set_modes(preview_path) + return self.get_preview_checksum(submission_id) + + def get_source_checksum(self, submission_id: int) -> str: + """Get the checksum of the source package for a submission.""" + return self._get_checksum(self._source_package_path(submission_id)) + + def does_source_exist(self, submission_id: int) -> bool: + """Determine whether source has been deposited for a submission.""" + return os.path.exists(self._source_package_path(submission_id)) + + def get_preview_checksum(self, submission_id: int) -> str: + """Get the checksum of the preview PDF for a submission.""" + return self._get_checksum(self._preview_path(submission_id)) + + def does_preview_exist(self, submission_id: int) -> bool: + """Determine whether a preview has been deposited for a submission.""" + return os.path.exists(self._preview_path(submission_id)) + + def _validate_submission_id(self, submission_id: int) -> None: + """Just because we have a type check here does not mean that it is impossible + for `submission_id` to be something other than an `int`. Since I'm + paranoid, we'll do a final check here to eliminate the possibility that a + (potentially dangerous) ``str``-like value sneaks by.""" + if not isinstance(submission_id, int): + raise SecurityError('Submission ID is improperly typed. This is a security concern.') + + def _submission_path(self, submission_id: int) -> Path: + """Gets classic filesystem structure is such as /{rootdir}/{first 4 digits of submission id}/{submission id}""" + self._validate_submission_id(submission_id) + shard_dir = self.root_dir / Path(str(submission_id)[:4]) + return shard_dir / Path(str(submission_id)) + + def _source_path(self, submission_id: int) -> Path: + """Get the source path for the submission_id""" + return self._submission_path(submission_id) / self.source_prefix + + def _source_package_path(self, submission_id: int) -> Path: + return self._submission_path(submission_id) / f'{submission_id}.tar.gz' + + def _preview_path(self, submission_id: int) -> Path: + return self._submission_path(submission_id) / f'{submission_id}.pdf' + + def _get_checksum(self, path: str) -> str: + hash_md5 = md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return urlsafe_b64encode(hash_md5.digest()).decode('utf-8') + + def _unpack_tarfile(self, tar_path: str, unpack_to: str) -> None: + result = Popen(['tar', '-xzf', tar_path, '-C', unpack_to]).wait() + if result != 0: + raise RuntimeError(f'tar exited with {result}') + + def _chmod_recurse(self, parent: Path, dir_mode: int, file_mode: int, + uid: int, gid: int) -> None: + """ + Recursively chmod and chown all directories and files. + + Parameters + ---------- + parent : str + Root directory for the operation (included). + dir_mode : int + Mode to set directories. + file_mode : int + Mode to set files. + uid : int + UID for chown. + gid : int + GID for chown. + + """ + if not os.path.isdir(parent): + os.chown(parent, uid, gid) + os.chmod(parent, file_mode) + return + + for path, directories, files in os.walk(parent): + for directory in directories: + os.chown(os.path.join(path, directory), uid, gid) + os.chmod(os.path.join(path, directory), dir_mode) + for fname in files: + os.chown(os.path.join(path, fname), uid, gid) + os.chmod(os.path.join(path, fname), file_mode) + os.chown(parent, uid, gid) + os.chmod(parent, dir_mode) + + def _set_modes(self, path: str) -> None: + dir_mode = self.source_dir_mode + file_mode = self.source_file_mode + source_uid = self.source_uid + source_gid = self.source_gid + self._chmod_recurse(path, dir_mode, file_mode, source_uid, source_gid) From 1c80f4c5e590ab15ab5fde95d9978b4622e8d8aa Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Fri, 20 Sep 2024 18:51:20 -0400 Subject: [PATCH 24/28] Working requirements.txt and Dockerfile The install of the requirements.txt file is stange because I want to avoid installing the large dependency list from arxiv-base. --- Dockerfile | 41 +- README.md | 25 +- poetry.lock | 2444 ----------------- pyproject.toml | 56 +- requirements-dev.txt | 35 + requirements.txt | 65 + .../implementations/legacy_implementation.py | 6 +- tests/__init__.py | 0 tests/conftest.py | 8 +- 9 files changed, 152 insertions(+), 2528 deletions(-) delete mode 100644 poetry.lock create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py diff --git a/Dockerfile b/Dockerfile index c96d215..779e487 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,33 @@ -FROM python:3.11 AS builder +FROM python:3.11-bookworm AS builder + +ADD https://astral.sh/uv/install.sh /install.sh +RUN chmod -R 655 /install.sh && /install.sh && rm /install.sh +ENV UV=/root/.cargo/bin/uv WORKDIR /usr/app -RUN python3 -m venv /venv +RUN $UV venv /venv ENV PATH="/venv/bin:$PATH" -RUN pip install --upgrade pip - -COPY . . -RUN pip install --no-cache-dir . +RUN $UV pip install --upgrade pip +COPY ./requirements.txt . +RUN $UV pip install --no-cache-dir --no-deps -r requirements.txt && \ + $UV cache clean -#FROM python:3.7 AS test_runner -#WORKDIR /tmp -#COPY --from=builder /venv /venv -#COPY --from=builder /usr/app/tests tests -#ENV PATH=/venv/bin:$PATH -# -## install test dependencies -#RUN pip install pytest -# -## run tests -#RUN pytest tests +FROM builder AS test +COPY ./requirements-dev.txt . +RUN $UV pip install --no-deps --no-cache-dir -r requirements-dev.txt && \ + $UV cache clean +COPY ./tests ./tests +COPY ./submit_ce ./submit_ce +RUN pytest tests -FROM python:3.11 AS service -WORKDIR /root/app/site-packages +FROM python:3.11.8-bookworm AS service +WORKDIR /usr/app COPY --from=builder /venv /venv ENV PATH=/venv/bin:$PATH +COPY ./submit_ce ./submit_ce +COPY ./main.py . +CMD ["uvicorn", "submit_ce.fastapi.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index c8f1800..8b0d0a1 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,25 @@ arXiv paper submission system To run the server, please execute the following from the root directory: ```bash -poetry install -# TODO How to get arxiv-base? -poetry shell # or however you do venvs -python test/make_test_db.py # to make sqlite dev db -fastapi dev main.py +# setup venv in your preferred way +python --version +# 3.11 + +pip install --no-deps -r requirements.txt +pip install --no-deps -r requirements-dev.txt + +# make sqlite dev db +python test/make_test_db.py + +python main.py ``` and open your browser at `http://localhost:8000/docs/` to see the docs. -## Running with Docker - -To run the server on a Docker container, please execute the following from the root directory: +## Build Docker Image ```bash -docker-compose up --build +docker build . -t arxiv/submit_ce ``` ## Tests @@ -28,6 +32,5 @@ docker-compose up --build To run the tests: ```bash -pip3 install pytest -PYTHONPATH=src pytest tests +pytest tests ``` diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 77838b1..0000000 --- a/poetry.lock +++ /dev/null @@ -1,2444 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.4.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -files = [ - {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, - {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] - -[[package]] -name = "astroid" -version = "1.6.6" -description = "A abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "astroid-1.6.6-py2.py3-none-any.whl", hash = "sha256:87de48a92e29cedf7210ffa853d11441e7ad94cb47bacd91b023499b51cbc756"}, - {file = "astroid-1.6.6.tar.gz", hash = "sha256:d25869fc7f44f1d9fb7d24fd7ea0639656f5355fc3089cd1f3d18c6ec6b124c7"}, -] - -[package.dependencies] -lazy-object-proxy = "*" -six = "*" -wrapt = "*" - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - -[[package]] -name = "backports-datetime-fromisoformat" -version = "2.0.2" -description = "Backport of Python 3.11's datetime.fromisoformat" -optional = false -python-versions = ">3" -files = [ - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09e70210726a70f3dd02ab9725bf2fcf469bda6d7554ea955588202e43e45b7d"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ec971f93353e0ee957b3bbb037d58371331eedb9bee1b6676a866f8be97289a4"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:191b0d327838eb21818e94a66b89118c086ada8f77ac9e6161980ef486fe0cbb"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00441807d47dec7b89acafaa6570f561c43f5c7b7934d86f101b783a365a0f0c"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0af8719e161ce2fa5f5e426cceef1ff04b611c69a61636c8a7bf25d687cfa0"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5afc32e1cdac293b054af04187d4adafcaceca99e12e5ff7807aee08074d85cb"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70b044fdd274e32ece726d30b1728b4a21bc78fed0be6294091c6f04228b39ec"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6f493622b06e23e10646df7ea23e0d8350e8b1caccb5509ea82f8c3e64db32c7"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55f59c88511dd15dabccf7916cbf23f8610203ac026454588084ddabf46127ee"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:65ca1f21319d78145456a70301396483ceebf078353641233494ea548ccc47db"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:79fc695afd66989f28e73de0ad91019abad789045577180dd482b6ede5bdca1e"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019a87bd234734c2badb4c3e1ce4e807c5f2081f398a45a320e0c4919e5cee13"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea2b77e8810b691f1dd347d5c3d4ad829d18a9e81a04a0ebbc958d431967db31"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:944c987b777d7a81d97c94cdee2a8597bf6bdc94090094689456d3b02760cb73"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:30a2ab8c1fe4eb0013e7fcca29906fbe54e89f9120731ea71032b048dcf2fa17"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e23b602892827e15b1b4f94c61d4872b03b5d13417344d9a8daec80277244a32"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64ec1ee18bc839847b067ab21a34a27e0d2cc4c6d041e4b05218cf6fed787740"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:54a3df9d6ae0e64b7677b9e3bba4fc7dce3ad56a3fa6bd66fb26796f8911de67"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:e54fa5663efcba6122bca037fd49220b7311e94cf6cc72e2f2a6f5d05c700bef"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ecff906ed4eb19808d8e4f0b141c14a1963d3688ba318c9e00aa7da7f71301"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e85f1ad56e2bcb24408e420de5508be47e54b0912ebe1325134e71837ec23a08"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36d5cbece09dff2a3f8f517f3cda64f2ccec56db07808714b1f122326cd76fbd"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d47e186dcc366e6063248730a137a90de0472b2aaa5047ef39104fcacbcbcdbe"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:3e9c81c6acc21953ffa9a627f15c4afcdbce6e456ca1d03e0d6dbf131429bd56"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5a2574f4b542b9679db2b8a786c779249d2d5057dad01f9433cfb79a921da92c"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:e62aa2eb6dc87a76a29b88601747925db439f793de7a8d2bbca4355e805088a6"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:964ec2d2c23908e96f1064560def1547b355e33e7c1ab418265e7e6242d25841"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8003f0cebeb6a5c47a1a871d0d09897d3dd54a9e1bcbe313f3e0463d470eed97"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c88e6660e1fb96476cb9df17d6f5002a2fb5c87546d62b2daa3642aa537e144"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7124cda6acdc66755df916c1f52b4e2e9cad85591d40bcd4a80341144fd98b32"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c2b0a4a407479964b3f79fde080aad066fe64a350a3fcbb729d3c44b0db21240"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:5616519470bc8131429266a869c3c5eeee5817a9a8357e2dd9c521383b774d1b"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2eb563509f19e803dbbef3e4901d9553c9c3ea2b73c8d8fb85219fc57f16787a"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d37d2f4238e0f412e56fe2c41e8e60bda93be0230d0ee846823b54254ccb95e0"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:7dcefbba71194c73b3b26593c2ea4ad254b19084d0eb83e98e2541651a692703"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:352f6b793cb402cc62c5b60ceab13d30c06fad1372869c716d4d07927b5c7c43"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d6a21b482001a9ea44f277dc21d9fb6590e543146aaabe816407d1b87cf41b"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f97285e80ea192357380cfd2fb2dce056ec65672597172f3af549dcf5d019b1e"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a5cfff34bf80f0cd2771da88bd898be1fa60250d6f2dd9e4a59885dbcb7aa7c"}, - {file = "backports_datetime_fromisoformat-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:ed392607d457b1ed50a88dcaf459e11d81c30a2f2d8dab818a1564de6897e76f"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0f24d2c596991e39dfaa60c685b8c69bc9b1da77e9baf2c453882adeec483b"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0083552588270acfaa31ac8de81b29786a1515d7608ff11ccdfcdffc2486212e"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f367b7d7bc00aa6738c95eb48b90817f7f9bd9c61592ceedda29ece97983ee3f"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e0914e357d8559f1821e46fd5ef5d3bd22ec568125ba9e680b6e70cdc352910"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d5a7cf9cdee221b7721544f424c69747a04091cbff53aa6ae8454644b59f9"}, - {file = "backports_datetime_fromisoformat-2.0.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a5e4c77a91db6f434c2eec46c0199d3617c19c812f0c74f7ed8e0f9779da9f0"}, - {file = "backports_datetime_fromisoformat-2.0.2.tar.gz", hash = "sha256:142313bde1f93b0ea55f20f5a6ea034f84c79713daeb252dc47d40019db3812f"}, -] - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.6.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, -] - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "coveralls" -version = "1.8.0" -description = "Show coverage stats online via coveralls.io" -optional = false -python-versions = "*" -files = [ - {file = "coveralls-1.8.0-py2.py3-none-any.whl", hash = "sha256:a8de28a5f04e418c7142b8ce6588c3a64245b433c458a5871cb043383667e4f2"}, - {file = "coveralls-1.8.0.tar.gz", hash = "sha256:c5e50b73b980d89308816b597e3e7bdeb0adedf831585d5c4ac967d576f8925d"}, -] - -[package.dependencies] -coverage = ">=3.6" -docopt = ">=0.6.1" -requests = ">=1.0.0" - -[package.extras] -yaml = ["PyYAML (>=3.10)"] - -[[package]] -name = "decorator" -version = "5.1.1" -description = "Decorators for Humans" -optional = false -python-versions = ">=3.5" -files = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] - -[[package]] -name = "dnspython" -version = "2.6.1" -description = "DNS toolkit" -optional = false -python-versions = ">=3.8" -files = [ - {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, - {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=41)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=0.9.25)"] -idna = ["idna (>=3.6)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - -[[package]] -name = "docker" -version = "7.1.0" -description = "A Python library for the Docker Engine API." -optional = false -python-versions = ">=3.8" -files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, -] - -[package.dependencies] -pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} -requests = ">=2.26.0" -urllib3 = ">=1.26.0" - -[package.extras] -dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] -docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] -ssh = ["paramiko (>=2.4.3)"] -websockets = ["websocket-client (>=1.3.0)"] - -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -optional = false -python-versions = "*" -files = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] - -[[package]] -name = "email-validator" -version = "2.2.0" -description = "A robust email address syntax and deliverability validation library." -optional = false -python-versions = ">=3.8" -files = [ - {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, - {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, -] - -[package.dependencies] -dnspython = ">=2.0.0" -idna = ">=2.0.0" - -[[package]] -name = "fastapi" -version = "0.114.2" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fastapi-0.114.2-py3-none-any.whl", hash = "sha256:44474a22913057b1acb973ab90f4b671ba5200482e7622816d79105dcece1ac5"}, - {file = "fastapi-0.114.2.tar.gz", hash = "sha256:0adb148b62edb09e8c6eeefa3ea934e8f276dabc038c5a82989ea6346050c3da"}, -] - -[package.dependencies] -email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} -fastapi-cli = {version = ">=0.0.5", extras = ["standard"], optional = true, markers = "extra == \"all\""} -httpx = {version = ">=0.23.0", optional = true, markers = "extra == \"all\""} -itsdangerous = {version = ">=1.1.0", optional = true, markers = "extra == \"all\""} -jinja2 = {version = ">=2.11.2", optional = true, markers = "extra == \"all\""} -orjson = {version = ">=3.2.1", optional = true, markers = "extra == \"all\""} -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -pydantic-extra-types = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} -pydantic-settings = {version = ">=2.0.0", optional = true, markers = "extra == \"all\""} -python-multipart = {version = ">=0.0.7", optional = true, markers = "extra == \"all\""} -pyyaml = {version = ">=5.3.1", optional = true, markers = "extra == \"all\""} -starlette = ">=0.37.2,<0.39.0" -typing-extensions = ">=4.8.0" -ujson = {version = ">=4.0.1,<4.0.2 || >4.0.2,<4.1.0 || >4.1.0,<4.2.0 || >4.2.0,<4.3.0 || >4.3.0,<5.0.0 || >5.0.0,<5.1.0 || >5.1.0", optional = true, markers = "extra == \"all\""} -uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers = "extra == \"all\""} - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "fastapi-cli" -version = "0.0.5" -description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fastapi_cli-0.0.5-py3-none-any.whl", hash = "sha256:e94d847524648c748a5350673546bbf9bcaeb086b33c24f2e82e021436866a46"}, - {file = "fastapi_cli-0.0.5.tar.gz", hash = "sha256:d30e1239c6f46fcb95e606f02cdda59a1e2fa778a54b64686b3ff27f6211ff9f"}, -] - -[package.dependencies] -typer = ">=0.12.3" -uvicorn = {version = ">=0.15.0", extras = ["standard"]} - -[package.extras] -standard = ["uvicorn[standard] (>=0.15.0)"] - -[[package]] -name = "fire" -version = "0.6.0" -description = "A library for automatically generating command line interfaces." -optional = false -python-versions = "*" -files = [ - {file = "fire-0.6.0.tar.gz", hash = "sha256:54ec5b996ecdd3c0309c800324a0703d6da512241bc73b553db959d98de0aa66"}, -] - -[package.dependencies] -six = "*" -termcolor = "*" - -[[package]] -name = "greenlet" -version = "3.1.0" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a814dc3100e8a046ff48faeaa909e80cdb358411a3d6dd5293158425c684eda8"}, - {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a771dc64fa44ebe58d65768d869fcfb9060169d203446c1d446e844b62bdfdca"}, - {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e49a65d25d7350cca2da15aac31b6f67a43d867448babf997fe83c7505f57bc"}, - {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cd8518eade968bc52262d8c46727cfc0826ff4d552cf0430b8d65aaf50bb91d"}, - {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76dc19e660baea5c38e949455c1181bc018893f25372d10ffe24b3ed7341fb25"}, - {file = "greenlet-3.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a5b1c22c82831f56f2f7ad9bbe4948879762fe0d59833a4a71f16e5fa0f682"}, - {file = "greenlet-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2651dfb006f391bcb240635079a68a261b227a10a08af6349cba834a2141efa1"}, - {file = "greenlet-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3e7e6ef1737a819819b1163116ad4b48d06cfdd40352d813bb14436024fcda99"}, - {file = "greenlet-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:ffb08f2a1e59d38c7b8b9ac8083c9c8b9875f0955b1e9b9b9a965607a51f8e54"}, - {file = "greenlet-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9730929375021ec90f6447bff4f7f5508faef1c02f399a1953870cdb78e0c345"}, - {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713d450cf8e61854de9420fb7eea8ad228df4e27e7d4ed465de98c955d2b3fa6"}, - {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c3446937be153718250fe421da548f973124189f18fe4575a0510b5c928f0cc"}, - {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ddc7bcedeb47187be74208bc652d63d6b20cb24f4e596bd356092d8000da6d6"}, - {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44151d7b81b9391ed759a2f2865bbe623ef00d648fed59363be2bbbd5154656f"}, - {file = "greenlet-3.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cea1cca3be76c9483282dc7760ea1cc08a6ecec1f0b6ca0a94ea0d17432da19"}, - {file = "greenlet-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:619935a44f414274a2c08c9e74611965650b730eb4efe4b2270f91df5e4adf9a"}, - {file = "greenlet-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:221169d31cada333a0c7fd087b957c8f431c1dba202c3a58cf5a3583ed973e9b"}, - {file = "greenlet-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:01059afb9b178606b4b6e92c3e710ea1635597c3537e44da69f4531e111dd5e9"}, - {file = "greenlet-3.1.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:24fc216ec7c8be9becba8b64a98a78f9cd057fd2dc75ae952ca94ed8a893bf27"}, - {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d07c28b85b350564bdff9f51c1c5007dfb2f389385d1bc23288de51134ca303"}, - {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:243a223c96a4246f8a30ea470c440fe9db1f5e444941ee3c3cd79df119b8eebf"}, - {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26811df4dc81271033a7836bc20d12cd30938e6bd2e9437f56fa03da81b0f8fc"}, - {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9d86401550b09a55410f32ceb5fe7efcd998bd2dad9e82521713cb148a4a15f"}, - {file = "greenlet-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9c1c4f1748ccac0bae1dbb465fb1a795a75aba8af8ca871503019f4285e2a"}, - {file = "greenlet-3.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cd468ec62257bb4544989402b19d795d2305eccb06cde5da0eb739b63dc04665"}, - {file = "greenlet-3.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a53dfe8f82b715319e9953330fa5c8708b610d48b5c59f1316337302af5c0811"}, - {file = "greenlet-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:28fe80a3eb673b2d5cc3b12eea468a5e5f4603c26aa34d88bf61bba82ceb2f9b"}, - {file = "greenlet-3.1.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:76b3e3976d2a452cba7aa9e453498ac72240d43030fdc6d538a72b87eaff52fd"}, - {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655b21ffd37a96b1e78cc48bf254f5ea4b5b85efaf9e9e2a526b3c9309d660ca"}, - {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6f4c2027689093775fd58ca2388d58789009116844432d920e9147f91acbe64"}, - {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76e5064fd8e94c3f74d9fd69b02d99e3cdb8fc286ed49a1f10b256e59d0d3a0b"}, - {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a4bf607f690f7987ab3291406e012cd8591a4f77aa54f29b890f9c331e84989"}, - {file = "greenlet-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037d9ac99540ace9424cb9ea89f0accfaff4316f149520b4ae293eebc5bded17"}, - {file = "greenlet-3.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:90b5bbf05fe3d3ef697103850c2ce3374558f6fe40fd57c9fac1bf14903f50a5"}, - {file = "greenlet-3.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:726377bd60081172685c0ff46afbc600d064f01053190e4450857483c4d44484"}, - {file = "greenlet-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:d46d5069e2eeda111d6f71970e341f4bd9aeeee92074e649ae263b834286ecc0"}, - {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81eeec4403a7d7684b5812a8aaa626fa23b7d0848edb3a28d2eb3220daddcbd0"}, - {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a3dae7492d16e85ea6045fd11cb8e782b63eac8c8d520c3a92c02ac4573b0a6"}, - {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b5ea3664eed571779403858d7cd0a9b0ebf50d57d2cdeafc7748e09ef8cd81a"}, - {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22f4e26400f7f48faef2d69c20dc055a1f3043d330923f9abe08ea0aecc44df"}, - {file = "greenlet-3.1.0-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13ff8c8e54a10472ce3b2a2da007f915175192f18e6495bad50486e87c7f6637"}, - {file = "greenlet-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9671e7282d8c6fcabc32c0fb8d7c0ea8894ae85cee89c9aadc2d7129e1a9954"}, - {file = "greenlet-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:184258372ae9e1e9bddce6f187967f2e08ecd16906557c4320e3ba88a93438c3"}, - {file = "greenlet-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:a0409bc18a9f85321399c29baf93545152d74a49d92f2f55302f122007cfda00"}, - {file = "greenlet-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9eb4a1d7399b9f3c7ac68ae6baa6be5f9195d1d08c9ddc45ad559aa6b556bce6"}, - {file = "greenlet-3.1.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a8870983af660798dc1b529e1fd6f1cefd94e45135a32e58bd70edd694540f33"}, - {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfcfb73aed40f550a57ea904629bdaf2e562c68fa1164fa4588e752af6efdc3f"}, - {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9482c2ed414781c0af0b35d9d575226da6b728bd1a720668fa05837184965b7"}, - {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d58ec349e0c2c0bc6669bf2cd4982d2f93bf067860d23a0ea1fe677b0f0b1e09"}, - {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd65695a8df1233309b701dec2539cc4b11e97d4fcc0f4185b4a12ce54db0491"}, - {file = "greenlet-3.1.0-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:665b21e95bc0fce5cab03b2e1d90ba9c66c510f1bb5fdc864f3a377d0f553f6b"}, - {file = "greenlet-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3c59a06c2c28a81a026ff11fbf012081ea34fb9b7052f2ed0366e14896f0a1d"}, - {file = "greenlet-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415b9494ff6240b09af06b91a375731febe0090218e2898d2b85f9b92abcda0"}, - {file = "greenlet-3.1.0-cp38-cp38-win32.whl", hash = "sha256:1544b8dd090b494c55e60c4ff46e238be44fdc472d2589e943c241e0169bcea2"}, - {file = "greenlet-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:7f346d24d74c00b6730440f5eb8ec3fe5774ca8d1c9574e8e57c8671bb51b910"}, - {file = "greenlet-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:db1b3ccb93488328c74e97ff888604a8b95ae4f35f4f56677ca57a4fc3a4220b"}, - {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44cd313629ded43bb3b98737bba2f3e2c2c8679b55ea29ed73daea6b755fe8e7"}, - {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fad7a051e07f64e297e6e8399b4d6a3bdcad3d7297409e9a06ef8cbccff4f501"}, - {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3967dcc1cd2ea61b08b0b276659242cbce5caca39e7cbc02408222fb9e6ff39"}, - {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d45b75b0f3fd8d99f62eb7908cfa6d727b7ed190737dec7fe46d993da550b81a"}, - {file = "greenlet-3.1.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d004db911ed7b6218ec5c5bfe4cf70ae8aa2223dffbb5b3c69e342bb253cb28"}, - {file = "greenlet-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9505a0c8579899057cbefd4ec34d865ab99852baf1ff33a9481eb3924e2da0b"}, - {file = "greenlet-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fd6e94593f6f9714dbad1aaba734b5ec04593374fa6638df61592055868f8b8"}, - {file = "greenlet-3.1.0-cp39-cp39-win32.whl", hash = "sha256:d0dd943282231480aad5f50f89bdf26690c995e8ff555f26d8a5b9887b559bcc"}, - {file = "greenlet-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:ac0adfdb3a21dc2a24ed728b61e72440d297d0fd3a577389df566651fcd08f97"}, - {file = "greenlet-3.1.0.tar.gz", hash = "sha256:b395121e9bbe8d02a750886f108d540abe66075e61e22f7353d9acb0b81be0f0"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.5" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, - {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.26.0)"] - -[[package]] -name = "httptools" -version = "0.6.1" -description = "A collection of framework independent HTTP protocol utils." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, - {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, - {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, - {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, - {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, - {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, - {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, - {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, - {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, - {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, - {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, - {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, - {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, - {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, - {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, - {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, - {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, - {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, - {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, - {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, - {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, - {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, - {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, - {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, - {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, - {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, - {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, - {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, - {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, - {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, - {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, - {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, - {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, - {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, - {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, - {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, -] - -[package.extras] -test = ["Cython (>=0.29.24,<0.30.0)"] - -[[package]] -name = "httpx" -version = "0.27.2" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, -] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.8" -files = [ - {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, - {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, -] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-path" -version = "0.3.3" -description = "JSONSchema Spec with object-oriented paths" -optional = false -python-versions = "<4.0.0,>=3.8.0" -files = [ - {file = "jsonschema_path-0.3.3-py3-none-any.whl", hash = "sha256:203aff257f8038cd3c67be614fe6b2001043408cb1b4e36576bc4921e09d83c4"}, - {file = "jsonschema_path-0.3.3.tar.gz", hash = "sha256:f02e5481a4288ec062f8e68c808569e427d905bedfecb7f2e4c69ef77957c382"}, -] - -[package.dependencies] -pathable = ">=0.4.1,<0.5.0" -PyYAML = ">=5.1" -referencing = ">=0.28.0,<0.36.0" -requests = ">=2.31.0,<3.0.0" - -[[package]] -name = "jsonschema-specifications" -version = "2023.12.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, - {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "lazy-object-proxy" -version = "1.10.0" -description = "A fast and thorough lazy object proxy." -optional = false -python-versions = ">=3.8" -files = [ - {file = "lazy-object-proxy-1.10.0.tar.gz", hash = "sha256:78247b6d45f43a52ef35c25b5581459e85117225408a4128a3daf8bf9648ac69"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:855e068b0358ab916454464a884779c7ffa312b8925c6f7401e952dcf3b89977"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab7004cf2e59f7c2e4345604a3e6ea0d92ac44e1c2375527d56492014e690c3"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0d2fc424e54c70c4bc06787e4072c4f3b1aa2f897dfdc34ce1013cf3ceef05"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e2adb09778797da09d2b5ebdbceebf7dd32e2c96f79da9052b2e87b6ea495895"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b1f711e2c6dcd4edd372cf5dec5c5a30d23bba06ee012093267b3376c079ec83"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-win32.whl", hash = "sha256:76a095cfe6045c7d0ca77db9934e8f7b71b14645f0094ffcd842349ada5c5fb9"}, - {file = "lazy_object_proxy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4f87d4ed9064b2628da63830986c3d2dca7501e6018347798313fcf028e2fd4"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fec03caabbc6b59ea4a638bee5fce7117be8e99a4103d9d5ad77f15d6f81020c"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c83f957782cbbe8136bee26416686a6ae998c7b6191711a04da776dc9e47d4"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009e6bb1f1935a62889ddc8541514b6a9e1fcf302667dcb049a0be5c8f613e56"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75fc59fc450050b1b3c203c35020bc41bd2695ed692a392924c6ce180c6f1dc9"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:782e2c9b2aab1708ffb07d4bf377d12901d7a1d99e5e410d648d892f8967ab1f"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-win32.whl", hash = "sha256:edb45bb8278574710e68a6b021599a10ce730d156e5b254941754a9cc0b17d03"}, - {file = "lazy_object_proxy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:e271058822765ad5e3bca7f05f2ace0de58a3f4e62045a8c90a0dfd2f8ad8cc6"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e98c8af98d5707dcdecc9ab0863c0ea6e88545d42ca7c3feffb6b4d1e370c7ba"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:952c81d415b9b80ea261d2372d2a4a2332a3890c2b83e0535f263ddfe43f0d43"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b39d3a151309efc8cc48675918891b865bdf742a8616a337cb0090791a0de9"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e221060b701e2aa2ea991542900dd13907a5c90fa80e199dbf5a03359019e7a3"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:92f09ff65ecff3108e56526f9e2481b8116c0b9e1425325e13245abfd79bdb1b"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-win32.whl", hash = "sha256:3ad54b9ddbe20ae9f7c1b29e52f123120772b06dbb18ec6be9101369d63a4074"}, - {file = "lazy_object_proxy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:127a789c75151db6af398b8972178afe6bda7d6f68730c057fbbc2e96b08d282"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9e4ed0518a14dd26092614412936920ad081a424bdcb54cc13349a8e2c6d106a"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9e6ed739285919aa9661a5bbed0aaf410aa60231373c5579c6b4801bd883c"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc0a92c02fa1ca1e84fc60fa258458e5bf89d90a1ddaeb8ed9cc3147f417255"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0aefc7591920bbd360d57ea03c995cebc204b424524a5bd78406f6e1b8b2a5d8"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5faf03a7d8942bb4476e3b62fd0f4cf94eaf4618e304a19865abf89a35c0bbee"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-win32.whl", hash = "sha256:e333e2324307a7b5d86adfa835bb500ee70bfcd1447384a822e96495796b0ca4"}, - {file = "lazy_object_proxy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:cb73507defd385b7705c599a94474b1d5222a508e502553ef94114a143ec6696"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:366c32fe5355ef5fc8a232c5436f4cc66e9d3e8967c01fb2e6302fd6627e3d94"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2297f08f08a2bb0d32a4265e98a006643cd7233fb7983032bd61ac7a02956b3b"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18dd842b49456aaa9a7cf535b04ca4571a302ff72ed8740d06b5adcd41fe0757"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:217138197c170a2a74ca0e05bddcd5f1796c735c37d0eee33e43259b192aa424"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a3a87cf1e133e5b1994144c12ca4aa3d9698517fe1e2ca82977781b16955658"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-win32.whl", hash = "sha256:30b339b2a743c5288405aa79a69e706a06e02958eab31859f7f3c04980853b70"}, - {file = "lazy_object_proxy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:a899b10e17743683b293a729d3a11f2f399e8a90c73b089e29f5d0fe3509f0dd"}, - {file = "lazy_object_proxy-1.10.0-pp310.pp311.pp312.pp38.pp39-none-any.whl", hash = "sha256:80fa48bd89c8f2f456fc0765c11c23bf5af827febacd2f523ca5bc1893fcc09d"}, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mimesis" -version = "18.0.0" -description = "Mimesis: Fake Data Generator." -optional = false -python-versions = "<4.0,>=3.10" -files = [ - {file = "mimesis-18.0.0-py3-none-any.whl", hash = "sha256:a51854a5ce63ebf2bd6a98e8841412e04cede38593be7e16d1d712848e6273df"}, - {file = "mimesis-18.0.0.tar.gz", hash = "sha256:7d7c76ecd680ae48afe8dc4413ef1ef1ee7ef20e16f9f9cb42892add642fc1b2"}, -] - -[package.extras] -factory = ["factory-boy (>=3.3.0,<4.0.0)"] -pytest = ["pytest (>=7.2,<8.0)"] - -[[package]] -name = "mypy" -version = "1.11.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, - {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, - {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, - {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, - {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, - {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, - {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, - {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, - {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, - {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, - {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, - {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, - {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, - {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, - {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, - {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, - {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, - {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, - {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -typing-extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "openapi-schema-validator" -version = "0.6.2" -description = "OpenAPI schema validation for Python" -optional = false -python-versions = ">=3.8.0,<4.0.0" -files = [ - {file = "openapi_schema_validator-0.6.2-py3-none-any.whl", hash = "sha256:c4887c1347c669eb7cded9090f4438b710845cd0f90d1fb9e1b3303fb37339f8"}, - {file = "openapi_schema_validator-0.6.2.tar.gz", hash = "sha256:11a95c9c9017912964e3e5f2545a5b11c3814880681fcacfb73b1759bb4f2804"}, -] - -[package.dependencies] -jsonschema = ">=4.19.1,<5.0.0" -jsonschema-specifications = ">=2023.5.2,<2024.0.0" -rfc3339-validator = "*" - -[[package]] -name = "openapi-spec-validator" -version = "0.7.1" -description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" -optional = false -python-versions = ">=3.8.0,<4.0.0" -files = [ - {file = "openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959"}, - {file = "openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7"}, -] - -[package.dependencies] -jsonschema = ">=4.18.0,<5.0.0" -jsonschema-path = ">=0.3.1,<0.4.0" -lazy-object-proxy = ">=1.7.1,<2.0.0" -openapi-schema-validator = ">=0.6.0,<0.7.0" - -[[package]] -name = "orjson" -version = "3.10.7" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.8" -files = [ - {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, - {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, - {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, - {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, - {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, - {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, - {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, - {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, - {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, - {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, - {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, - {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, - {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, - {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, - {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, - {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, - {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, - {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, - {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, - {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, - {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, - {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, - {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, - {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, - {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, - {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, - {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, - {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, - {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, - {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, - {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, -] - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - -[[package]] -name = "pathable" -version = "0.4.3" -description = "Object-oriented paths" -optional = false -python-versions = ">=3.7.0,<4.0.0" -files = [ - {file = "pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14"}, - {file = "pathable-0.4.3.tar.gz", hash = "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab"}, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - -[[package]] -name = "pydantic" -version = "2.9.1" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"}, - {file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.23.3" -typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, -] - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] - -[[package]] -name = "pydantic-core" -version = "2.23.3" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"}, - {file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"}, - {file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"}, - {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"}, - {file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"}, - {file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"}, - {file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"}, - {file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"}, - {file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"}, - {file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"}, - {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"}, - {file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"}, - {file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"}, - {file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"}, - {file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"}, - {file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"}, - {file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"}, - {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"}, - {file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"}, - {file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"}, - {file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"}, - {file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"}, - {file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"}, - {file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"}, - {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"}, - {file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"}, - {file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"}, - {file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"}, - {file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"}, - {file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"}, - {file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"}, - {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"}, - {file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"}, - {file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"}, - {file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"}, - {file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"}, - {file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"}, - {file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"}, - {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"}, - {file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"}, - {file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"}, - {file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"}, - {file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"}, - {file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"}, - {file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pydantic-extra-types" -version = "2.9.0" -description = "Extra Pydantic types." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_extra_types-2.9.0-py3-none-any.whl", hash = "sha256:f0bb975508572ba7bf3390b7337807588463b7248587e69f43b1ad7c797530d0"}, - {file = "pydantic_extra_types-2.9.0.tar.gz", hash = "sha256:e061c01636188743bb69f368dcd391f327b8cfbfede2fe1cbb1211b06601ba3b"}, -] - -[package.dependencies] -pydantic = ">=2.5.2" - -[package.extras] -all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<3)", "pytz (>=2024.1)", "semver (>=3.0.2)", "tzdata (>=2024.1)"] -pendulum = ["pendulum (>=3.0.0,<4.0.0)"] -phonenumbers = ["phonenumbers (>=8,<9)"] -pycountry = ["pycountry (>=23)"] -python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<3)"] -semver = ["semver (>=3.0.2)"] - -[[package]] -name = "pydantic-settings" -version = "2.5.2" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907"}, - {file = "pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" - -[package.extras] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - -[[package]] -name = "pydocstyle" -version = "3.0.0" -description = "Python docstring style checker" -optional = false -python-versions = "*" -files = [ - {file = "pydocstyle-3.0.0-py2-none-any.whl", hash = "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8"}, - {file = "pydocstyle-3.0.0-py3-none-any.whl", hash = "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039"}, - {file = "pydocstyle-3.0.0.tar.gz", hash = "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4"}, -] - -[package.dependencies] -six = "*" -snowballstemmer = "*" - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pylint" -version = "1.9.4" -description = "python code static checker" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pylint-1.9.4-py2.py3-none-any.whl", hash = "sha256:02c2b6d268695a8b64ad61847f92e611e6afcff33fd26c3a2125370c4662905d"}, - {file = "pylint-1.9.4.tar.gz", hash = "sha256:ee1e85575587c5b58ddafa25e1c1b01691ef172e139fc25585e5d3f02451da93"}, -] - -[package.dependencies] -astroid = ">=1.6,<2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -isort = ">=4.2.5" -mccabe = "*" -six = "*" - -[[package]] -name = "pytest" -version = "8.3.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "5.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "python-multipart" -version = "0.0.9" -description = "A streaming multipart parser for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, -] - -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - -[[package]] -name = "pytz" -version = "2018.7" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2018.7-py2.py3-none-any.whl", hash = "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6"}, - {file = "pytz-2018.7.tar.gz", hash = "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca"}, -] - -[[package]] -name = "pywin32" -version = "306" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -files = [ - {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, - {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, - {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, - {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, - {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, - {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, - {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, - {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, - {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, - {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, - {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, - {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, - {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, - {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "referencing" -version = "0.35.1" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, - {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -description = "A utility belt for advanced users of python-requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, - {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, -] - -[package.dependencies] -requests = ">=2.0.1,<3.0.0" - -[[package]] -name = "retry" -version = "0.9.2" -description = "Easy to use retry decorator." -optional = false -python-versions = "*" -files = [ - {file = "retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606"}, - {file = "retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4"}, -] - -[package.dependencies] -decorator = ">=3.4.2" -py = ">=1.4.26,<2.0.0" - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -description = "A pure python RFC3339 validator" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, - {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, -] - -[package.dependencies] -six = "*" - -[[package]] -name = "rich" -version = "13.8.1" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, - {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rpds-py" -version = "0.20.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, - {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, - {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, - {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, - {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, - {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, - {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, - {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, - {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, - {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, - {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, - {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, - {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, - {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, - {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, -] - -[[package]] -name = "semver" -version = "3.0.2" -description = "Python helper for Semantic Versioning (https://semver.org)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, - {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.34" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "SQLAlchemy-2.0.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:95d0b2cf8791ab5fb9e3aa3d9a79a0d5d51f55b6357eecf532a120ba3b5524db"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:243f92596f4fd4c8bd30ab8e8dd5965afe226363d75cab2468f2c707f64cd83b"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ea54f7300553af0a2a7235e9b85f4204e1fc21848f917a3213b0e0818de9a24"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:173f5f122d2e1bff8fbd9f7811b7942bead1f5e9f371cdf9e670b327e6703ebd"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:196958cde924a00488e3e83ff917be3b73cd4ed8352bbc0f2989333176d1c54d"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd90c221ed4e60ac9d476db967f436cfcecbd4ef744537c0f2d5291439848768"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-win32.whl", hash = "sha256:3166dfff2d16fe9be3241ee60ece6fcb01cf8e74dd7c5e0b64f8e19fab44911b"}, - {file = "SQLAlchemy-2.0.34-cp310-cp310-win_amd64.whl", hash = "sha256:6831a78bbd3c40f909b3e5233f87341f12d0b34a58f14115c9e94b4cdaf726d3"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7db3db284a0edaebe87f8f6642c2b2c27ed85c3e70064b84d1c9e4ec06d5d84"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:430093fce0efc7941d911d34f75a70084f12f6ca5c15d19595c18753edb7c33b"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79cb400c360c7c210097b147c16a9e4c14688a6402445ac848f296ade6283bbc"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb1b30f31a36c7f3fee848391ff77eebdd3af5750bf95fbf9b8b5323edfdb4ec"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fddde2368e777ea2a4891a3fb4341e910a056be0bb15303bf1b92f073b80c02"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80bd73ea335203b125cf1d8e50fef06be709619eb6ab9e7b891ea34b5baa2287"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-win32.whl", hash = "sha256:6daeb8382d0df526372abd9cb795c992e18eed25ef2c43afe518c73f8cccb721"}, - {file = "SQLAlchemy-2.0.34-cp311-cp311-win_amd64.whl", hash = "sha256:5bc08e75ed11693ecb648b7a0a4ed80da6d10845e44be0c98c03f2f880b68ff4"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:53e68b091492c8ed2bd0141e00ad3089bcc6bf0e6ec4142ad6505b4afe64163e"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bcd18441a49499bf5528deaa9dee1f5c01ca491fc2791b13604e8f972877f812"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165bbe0b376541092bf49542bd9827b048357f4623486096fc9aaa6d4e7c59a2"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3330415cd387d2b88600e8e26b510d0370db9b7eaf984354a43e19c40df2e2b"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97b850f73f8abbffb66ccbab6e55a195a0eb655e5dc74624d15cff4bfb35bd74"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee4c6917857fd6121ed84f56d1dc78eb1d0e87f845ab5a568aba73e78adf83"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-win32.whl", hash = "sha256:fbb034f565ecbe6c530dff948239377ba859420d146d5f62f0271407ffb8c580"}, - {file = "SQLAlchemy-2.0.34-cp312-cp312-win_amd64.whl", hash = "sha256:707c8f44931a4facd4149b52b75b80544a8d824162602b8cd2fe788207307f9a"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:24af3dc43568f3780b7e1e57c49b41d98b2d940c1fd2e62d65d3928b6f95f021"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60ed6ef0a35c6b76b7640fe452d0e47acc832ccbb8475de549a5cc5f90c2c06"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413c85cd0177c23e32dee6898c67a5f49296640041d98fddb2c40888fe4daa2e"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:25691f4adfb9d5e796fd48bf1432272f95f4bbe5f89c475a788f31232ea6afba"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:526ce723265643dbc4c7efb54f56648cc30e7abe20f387d763364b3ce7506c82"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-win32.whl", hash = "sha256:13be2cc683b76977a700948411a94c67ad8faf542fa7da2a4b167f2244781cf3"}, - {file = "SQLAlchemy-2.0.34-cp37-cp37m-win_amd64.whl", hash = "sha256:e54ef33ea80d464c3dcfe881eb00ad5921b60f8115ea1a30d781653edc2fd6a2"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:43f28005141165edd11fbbf1541c920bd29e167b8bbc1fb410d4fe2269c1667a"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b68094b165a9e930aedef90725a8fcfafe9ef95370cbb54abc0464062dbf808f"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1e03db964e9d32f112bae36f0cc1dcd1988d096cfd75d6a588a3c3def9ab2b"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:203d46bddeaa7982f9c3cc693e5bc93db476ab5de9d4b4640d5c99ff219bee8c"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ae92bebca3b1e6bd203494e5ef919a60fb6dfe4d9a47ed2453211d3bd451b9f5"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9661268415f450c95f72f0ac1217cc6f10256f860eed85c2ae32e75b60278ad8"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-win32.whl", hash = "sha256:895184dfef8708e15f7516bd930bda7e50ead069280d2ce09ba11781b630a434"}, - {file = "SQLAlchemy-2.0.34-cp38-cp38-win_amd64.whl", hash = "sha256:6e7cde3a2221aa89247944cafb1b26616380e30c63e37ed19ff0bba5e968688d"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbcdf987f3aceef9763b6d7b1fd3e4ee210ddd26cac421d78b3c206d07b2700b"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ce119fc4ce0d64124d37f66a6f2a584fddc3c5001755f8a49f1ca0a177ef9796"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a17d8fac6df9835d8e2b4c5523666e7051d0897a93756518a1fe101c7f47f2f0"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ebc11c54c6ecdd07bb4efbfa1554538982f5432dfb8456958b6d46b9f834bb7"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e6965346fc1491a566e019a4a1d3dfc081ce7ac1a736536367ca305da6472a8"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:220574e78ad986aea8e81ac68821e47ea9202b7e44f251b7ed8c66d9ae3f4278"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-win32.whl", hash = "sha256:b75b00083e7fe6621ce13cfce9d4469c4774e55e8e9d38c305b37f13cf1e874c"}, - {file = "SQLAlchemy-2.0.34-cp39-cp39-win_amd64.whl", hash = "sha256:c29d03e0adf3cc1a8c3ec62d176824972ae29b67a66cbb18daff3062acc6faa8"}, - {file = "SQLAlchemy-2.0.34-py3-none-any.whl", hash = "sha256:7286c353ee6475613d8beff83167374006c6b3e3f0e6491bfe8ca610eb1dec0f"}, - {file = "sqlalchemy-2.0.34.tar.gz", hash = "sha256:10d8f36990dd929690666679b0f42235c159a7051534adb135728ee52828dd22"}, -] - -[package.dependencies] -greenlet = {version = "!=0.4.17", markers = "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\")"} -typing-extensions = ">=4.6.0" - -[package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] -aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] -aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] -mssql = ["pyodbc"] -mssql-pymssql = ["pymssql"] -mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] -mysql = ["mysqlclient (>=1.4.0)"] -mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] -postgresql-psycopg2binary = ["psycopg2-binary"] -postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] -pymysql = ["pymysql"] -sqlcipher = ["sqlcipher3_binary"] - -[[package]] -name = "starlette" -version = "0.38.5" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.8" -files = [ - {file = "starlette-0.38.5-py3-none-any.whl", hash = "sha256:632f420a9d13e3ee2a6f18f437b0a9f1faecb0bc42e1942aa2ea0e379a4c4206"}, - {file = "starlette-0.38.5.tar.gz", hash = "sha256:04a92830a9b6eb1442c766199d62260c3d4dc9c4f9188360626b1e0273cb7077"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] - -[[package]] -name = "termcolor" -version = "2.4.0" -description = "ANSI color formatting for output in terminal" -optional = false -python-versions = ">=3.8" -files = [ - {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, - {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, -] - -[package.extras] -tests = ["pytest", "pytest-cov"] - -[[package]] -name = "typer" -version = "0.12.5" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.7" -files = [ - {file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, - {file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "ujson" -version = "5.10.0" -description = "Ultra fast JSON encoder and decoder for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, - {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, - {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, - {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, - {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, - {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, - {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, - {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, - {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, - {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, - {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, - {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, - {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, - {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, - {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, - {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, - {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, - {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, - {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, - {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, - {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, - {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, - {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, - {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, - {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, -] - -[[package]] -name = "unidecode" -version = "1.3.8" -description = "ASCII transliterations of Unicode text" -optional = false -python-versions = ">=3.5" -files = [ - {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, - {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "uvicorn" -version = "0.30.6" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.8" -files = [ - {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, - {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} -h11 = ">=0.8" -httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} -python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "uvloop" -version = "0.20.0" -description = "Fast implementation of asyncio event loop on top of libuv" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9ebafa0b96c62881d5cafa02d9da2e44c23f9f0cd829f3a32a6aff771449c996"}, - {file = "uvloop-0.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:35968fc697b0527a06e134999eef859b4034b37aebca537daeb598b9d45a137b"}, - {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b16696f10e59d7580979b420eedf6650010a4a9c3bd8113f24a103dfdb770b10"}, - {file = "uvloop-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b04d96188d365151d1af41fa2d23257b674e7ead68cfd61c725a422764062ae"}, - {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:94707205efbe809dfa3a0d09c08bef1352f5d3d6612a506f10a319933757c006"}, - {file = "uvloop-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89e8d33bb88d7263f74dc57d69f0063e06b5a5ce50bb9a6b32f5fcbe655f9e73"}, - {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037"}, - {file = "uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9"}, - {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e"}, - {file = "uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756"}, - {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0"}, - {file = "uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf"}, - {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d"}, - {file = "uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e"}, - {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9"}, - {file = "uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab"}, - {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5"}, - {file = "uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00"}, - {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0e94b221295b5e69de57a1bd4aeb0b3a29f61be6e1b478bb8a69a73377db7ba"}, - {file = "uvloop-0.20.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fee6044b64c965c425b65a4e17719953b96e065c5b7e09b599ff332bb2744bdf"}, - {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:265a99a2ff41a0fd56c19c3838b29bf54d1d177964c300dad388b27e84fd7847"}, - {file = "uvloop-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10c2956efcecb981bf9cfb8184d27d5d64b9033f917115a960b83f11bfa0d6b"}, - {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e7d61fe8e8d9335fac1bf8d5d82820b4808dd7a43020c149b63a1ada953d48a6"}, - {file = "uvloop-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2beee18efd33fa6fdb0976e18475a4042cd31c7433c866e8a09ab604c7c22ff2"}, - {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8c36fdf3e02cec92aed2d44f63565ad1522a499c654f07935c8f9d04db69e95"}, - {file = "uvloop-0.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0fac7be202596c7126146660725157d4813aa29a4cc990fe51346f75ff8fde7"}, - {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0fba61846f294bce41eb44d60d58136090ea2b5b99efd21cbdf4e21927c56a"}, - {file = "uvloop-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95720bae002ac357202e0d866128eb1ac82545bcf0b549b9abe91b5178d9b541"}, - {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:36c530d8fa03bfa7085af54a48f2ca16ab74df3ec7108a46ba82fd8b411a2315"}, - {file = "uvloop-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e97152983442b499d7a71e44f29baa75b3b02e65d9c44ba53b10338e98dedb66"}, - {file = "uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469"}, -] - -[package.extras] -docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] - -[[package]] -name = "watchfiles" -version = "0.24.0" -description = "Simple, modern and high performance file watching and code reload in python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "watchfiles-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:083dc77dbdeef09fa44bb0f4d1df571d2e12d8a8f985dccde71ac3ac9ac067a0"}, - {file = "watchfiles-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e94e98c7cb94cfa6e071d401ea3342767f28eb5a06a58fafdc0d2a4974f4f35c"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82ae557a8c037c42a6ef26c494d0631cacca040934b101d001100ed93d43f361"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acbfa31e315a8f14fe33e3542cbcafc55703b8f5dcbb7c1eecd30f141df50db3"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74fdffce9dfcf2dc296dec8743e5b0332d15df19ae464f0e249aa871fc1c571"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:449f43f49c8ddca87c6b3980c9284cab6bd1f5c9d9a2b00012adaaccd5e7decd"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4abf4ad269856618f82dee296ac66b0cd1d71450fc3c98532d93798e73399b7a"}, - {file = "watchfiles-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f895d785eb6164678ff4bb5cc60c5996b3ee6df3edb28dcdeba86a13ea0465e"}, - {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7ae3e208b31be8ce7f4c2c0034f33406dd24fbce3467f77223d10cd86778471c"}, - {file = "watchfiles-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2efec17819b0046dde35d13fb8ac7a3ad877af41ae4640f4109d9154ed30a188"}, - {file = "watchfiles-0.24.0-cp310-none-win32.whl", hash = "sha256:6bdcfa3cd6fdbdd1a068a52820f46a815401cbc2cb187dd006cb076675e7b735"}, - {file = "watchfiles-0.24.0-cp310-none-win_amd64.whl", hash = "sha256:54ca90a9ae6597ae6dc00e7ed0a040ef723f84ec517d3e7ce13e63e4bc82fa04"}, - {file = "watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428"}, - {file = "watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15"}, - {file = "watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823"}, - {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab"}, - {file = "watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec"}, - {file = "watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d"}, - {file = "watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c"}, - {file = "watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633"}, - {file = "watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a"}, - {file = "watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f"}, - {file = "watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234"}, - {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef"}, - {file = "watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968"}, - {file = "watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444"}, - {file = "watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896"}, - {file = "watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418"}, - {file = "watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48"}, - {file = "watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab"}, - {file = "watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f"}, - {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b"}, - {file = "watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18"}, - {file = "watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07"}, - {file = "watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366"}, - {file = "watchfiles-0.24.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ee82c98bed9d97cd2f53bdb035e619309a098ea53ce525833e26b93f673bc318"}, - {file = "watchfiles-0.24.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fd92bbaa2ecdb7864b7600dcdb6f2f1db6e0346ed425fbd01085be04c63f0b05"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f83df90191d67af5a831da3a33dd7628b02a95450e168785586ed51e6d28943c"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fca9433a45f18b7c779d2bae7beeec4f740d28b788b117a48368d95a3233ed83"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b995bfa6bf01a9e09b884077a6d37070464b529d8682d7691c2d3b540d357a0c"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed9aba6e01ff6f2e8285e5aa4154e2970068fe0fc0998c4380d0e6278222269b"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5171ef898299c657685306d8e1478a45e9303ddcd8ac5fed5bd52ad4ae0b69b"}, - {file = "watchfiles-0.24.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4933a508d2f78099162da473841c652ad0de892719043d3f07cc83b33dfd9d91"}, - {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95cf3b95ea665ab03f5a54765fa41abf0529dbaf372c3b83d91ad2cfa695779b"}, - {file = "watchfiles-0.24.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:01def80eb62bd5db99a798d5e1f5f940ca0a05986dcfae21d833af7a46f7ee22"}, - {file = "watchfiles-0.24.0-cp38-none-win32.whl", hash = "sha256:4d28cea3c976499475f5b7a2fec6b3a36208656963c1a856d328aeae056fc5c1"}, - {file = "watchfiles-0.24.0-cp38-none-win_amd64.whl", hash = "sha256:21ab23fdc1208086d99ad3f69c231ba265628014d4aed31d4e8746bd59e88cd1"}, - {file = "watchfiles-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b665caeeda58625c3946ad7308fbd88a086ee51ccb706307e5b1fa91556ac886"}, - {file = "watchfiles-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5c51749f3e4e269231510da426ce4a44beb98db2dce9097225c338f815b05d4f"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b2509f08761f29a0fdad35f7e1638b8ab1adfa2666d41b794090361fb8b855"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a60e2bf9dc6afe7f743e7c9b149d1fdd6dbf35153c78fe3a14ae1a9aee3d98b"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7d9b87c4c55e3ea8881dfcbf6d61ea6775fffed1fedffaa60bd047d3c08c430"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:78470906a6be5199524641f538bd2c56bb809cd4bf29a566a75051610bc982c3"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07cdef0c84c03375f4e24642ef8d8178e533596b229d32d2bbd69e5128ede02a"}, - {file = "watchfiles-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d337193bbf3e45171c8025e291530fb7548a93c45253897cd764a6a71c937ed9"}, - {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ec39698c45b11d9694a1b635a70946a5bad066b593af863460a8e600f0dff1ca"}, - {file = "watchfiles-0.24.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e28d91ef48eab0afb939fa446d8ebe77e2f7593f5f463fd2bb2b14132f95b6e"}, - {file = "watchfiles-0.24.0-cp39-none-win32.whl", hash = "sha256:7138eff8baa883aeaa074359daabb8b6c1e73ffe69d5accdc907d62e50b1c0da"}, - {file = "watchfiles-0.24.0-cp39-none-win_amd64.whl", hash = "sha256:b3ef2c69c655db63deb96b3c3e587084612f9b1fa983df5e0c3379d41307467f"}, - {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:632676574429bee8c26be8af52af20e0c718cc7f5f67f3fb658c71928ccd4f7f"}, - {file = "watchfiles-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a2a9891723a735d3e2540651184be6fd5b96880c08ffe1a98bae5017e65b544b"}, - {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fa2bc0efef3e209a8199fd111b8969fe9db9c711acc46636686331eda7dd4"}, - {file = "watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a"}, - {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:96619302d4374de5e2345b2b622dc481257a99431277662c30f606f3e22f42be"}, - {file = "watchfiles-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:85d5f0c7771dcc7a26c7a27145059b6bb0ce06e4e751ed76cdf123d7039b60b5"}, - {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951088d12d339690a92cef2ec5d3cfd957692834c72ffd570ea76a6790222777"}, - {file = "watchfiles-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fb58bcaa343fedc6a9e91f90195b20ccb3135447dc9e4e2570c3a39565853e"}, - {file = "watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1"}, -] - -[package.dependencies] -anyio = ">=3.0.0" - -[[package]] -name = "websockets" -version = "13.0.1" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1841c9082a3ba4a05ea824cf6d99570a6a2d8849ef0db16e9c826acb28089e8f"}, - {file = "websockets-13.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c5870b4a11b77e4caa3937142b650fbbc0914a3e07a0cf3131f35c0587489c1c"}, - {file = "websockets-13.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d3d1f2eb79fe7b0fb02e599b2bf76a7619c79300fc55f0b5e2d382881d4f7f"}, - {file = "websockets-13.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15c7d62ee071fa94a2fc52c2b472fed4af258d43f9030479d9c4a2de885fd543"}, - {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6724b554b70d6195ba19650fef5759ef11346f946c07dbbe390e039bcaa7cc3d"}, - {file = "websockets-13.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a952fa2ae57a42ba7951e6b2605e08a24801a4931b5644dfc68939e041bc7f"}, - {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17118647c0ea14796364299e942c330d72acc4b248e07e639d34b75067b3cdd8"}, - {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64a11aae1de4c178fa653b07d90f2fb1a2ed31919a5ea2361a38760192e1858b"}, - {file = "websockets-13.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0617fd0b1d14309c7eab6ba5deae8a7179959861846cbc5cb528a7531c249448"}, - {file = "websockets-13.0.1-cp310-cp310-win32.whl", hash = "sha256:11f9976ecbc530248cf162e359a92f37b7b282de88d1d194f2167b5e7ad80ce3"}, - {file = "websockets-13.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c3c493d0e5141ec055a7d6809a28ac2b88d5b878bb22df8c621ebe79a61123d0"}, - {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7"}, - {file = "websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4"}, - {file = "websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2"}, - {file = "websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0"}, - {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e"}, - {file = "websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462"}, - {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501"}, - {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418"}, - {file = "websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df"}, - {file = "websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f"}, - {file = "websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075"}, - {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a"}, - {file = "websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956"}, - {file = "websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af"}, - {file = "websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf"}, - {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c"}, - {file = "websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4"}, - {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab"}, - {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d"}, - {file = "websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237"}, - {file = "websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185"}, - {file = "websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99"}, - {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa"}, - {file = "websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231"}, - {file = "websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9"}, - {file = "websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75"}, - {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553"}, - {file = "websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920"}, - {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329"}, - {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7"}, - {file = "websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2"}, - {file = "websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb"}, - {file = "websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b"}, - {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b74593e9acf18ea5469c3edaa6b27fa7ecf97b30e9dabd5a94c4c940637ab96e"}, - {file = "websockets-13.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:132511bfd42e77d152c919147078460c88a795af16b50e42a0bd14f0ad71ddd2"}, - {file = "websockets-13.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:165bedf13556f985a2aa064309baa01462aa79bf6112fbd068ae38993a0e1f1b"}, - {file = "websockets-13.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e801ca2f448850685417d723ec70298feff3ce4ff687c6f20922c7474b4746ae"}, - {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30d3a1f041360f029765d8704eae606781e673e8918e6b2c792e0775de51352f"}, - {file = "websockets-13.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67648f5e50231b5a7f6d83b32f9c525e319f0ddc841be0de64f24928cd75a603"}, - {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4f0426d51c8f0926a4879390f53c7f5a855e42d68df95fff6032c82c888b5f36"}, - {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ef48e4137e8799998a343706531e656fdec6797b80efd029117edacb74b0a10a"}, - {file = "websockets-13.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:249aab278810bee585cd0d4de2f08cfd67eed4fc75bde623be163798ed4db2eb"}, - {file = "websockets-13.0.1-cp38-cp38-win32.whl", hash = "sha256:06c0a667e466fcb56a0886d924b5f29a7f0886199102f0a0e1c60a02a3751cb4"}, - {file = "websockets-13.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1f3cf6d6ec1142412d4535adabc6bd72a63f5f148c43fe559f06298bc21953c9"}, - {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1fa082ea38d5de51dd409434edc27c0dcbd5fed2b09b9be982deb6f0508d25bc"}, - {file = "websockets-13.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4a365bcb7be554e6e1f9f3ed64016e67e2fa03d7b027a33e436aecf194febb63"}, - {file = "websockets-13.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:10a0dc7242215d794fb1918f69c6bb235f1f627aaf19e77f05336d147fce7c37"}, - {file = "websockets-13.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59197afd478545b1f73367620407b0083303569c5f2d043afe5363676f2697c9"}, - {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d20516990d8ad557b5abeb48127b8b779b0b7e6771a265fa3e91767596d7d97"}, - {file = "websockets-13.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1a2e272d067030048e1fe41aa1ec8cfbbaabce733b3d634304fa2b19e5c897f"}, - {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad327ac80ba7ee61da85383ca8822ff808ab5ada0e4a030d66703cc025b021c4"}, - {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:518f90e6dd089d34eaade01101fd8a990921c3ba18ebbe9b0165b46ebff947f0"}, - {file = "websockets-13.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68264802399aed6fe9652e89761031acc734fc4c653137a5911c2bfa995d6d6d"}, - {file = "websockets-13.0.1-cp39-cp39-win32.whl", hash = "sha256:a5dc0c42ded1557cc7c3f0240b24129aefbad88af4f09346164349391dea8e58"}, - {file = "websockets-13.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b448a0690ef43db5ef31b3a0d9aea79043882b4632cfc3eaab20105edecf6097"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:faef9ec6354fe4f9a2c0bbb52fb1ff852effc897e2a4501e25eb3a47cb0a4f89"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:03d3f9ba172e0a53e37fa4e636b86cc60c3ab2cfee4935e66ed1d7acaa4625ad"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d450f5a7a35662a9b91a64aefa852f0c0308ee256122f5218a42f1d13577d71e"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f55b36d17ac50aa8a171b771e15fbe1561217510c8768af3d546f56c7576cdc"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b9c006cac63772b31abbcd3e3abb6228233eec966bf062e89e7fa7ae0b7333"}, - {file = "websockets-13.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b79915a1179a91f6c5f04ece1e592e2e8a6bd245a0e45d12fd56b2b59e559a32"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f40de079779acbcdbb6ed4c65af9f018f8b77c5ec4e17a4b737c05c2db554491"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e4ba642fc87fa532bac07e5ed7e19d56940b6af6a8c61d4429be48718a380f"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a02b0161c43cc9e0232711eff846569fad6ec836a7acab16b3cf97b2344c060"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6aa74a45d4cdc028561a7d6ab3272c8b3018e23723100b12e58be9dfa5a24491"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00fd961943b6c10ee6f0b1130753e50ac5dcd906130dcd77b0003c3ab797d026"}, - {file = "websockets-13.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d93572720d781331fb10d3da9ca1067817d84ad1e7c31466e9f5e59965618096"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:71e6e5a3a3728886caee9ab8752e8113670936a193284be9d6ad2176a137f376"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c4a6343e3b0714e80da0b0893543bf9a5b5fa71b846ae640e56e9abc6fbc4c83"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a678532018e435396e37422a95e3ab87f75028ac79570ad11f5bf23cd2a7d8c"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6716c087e4aa0b9260c4e579bb82e068f84faddb9bfba9906cb87726fa2e870"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e33505534f3f673270dd67f81e73550b11de5b538c56fe04435d63c02c3f26b5"}, - {file = "websockets-13.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acab3539a027a85d568c2573291e864333ec9d912675107d6efceb7e2be5d980"}, - {file = "websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817"}, - {file = "websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e"}, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.6" -files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.11" -content-hash = "9dd88de47ddc807b00d5c9bd21c5b357c45431a5176fd3b2a72e6a059f5f45ae" diff --git a/pyproject.toml b/pyproject.toml index 7a3c6ff..93618e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,60 +1,14 @@ [project] name = "submit-ce" version = "0.1.0" -description = "Add your description here" +description = "Paper submission system." readme = "README.md" requires-python = ">=3.11" -dependencies = [] +dynamic = ["dependencies", "optional-dependencies"] -[tool.poetry] -name = "submit-ce" -version = "0.1.0" -description = "arXiv Submit" -authors = ["Brian D. Caruso "] -readme = "README.md" -packages = [{include = "arxiv", from="src"}] - -[tool.poetry.dependencies] -python = "^3.11" -#arxiv-base = {git = "https://github.com/arXiv/arxiv-base.git", ref = "f8827cc8" } - -backports-datetime-fromisoformat = "*" -jsonschema = "*" -mimesis = "*" -mypy = "*" -mypy_extensions = "*" - -python-dateutil = "*" -pytz = "==2018.7" -pyyaml = ">=4.2b1" -retry = "*" -unidecode = "*" -urllib3 = ">=1.24.2" -semver = "^3.0.2" -requests-toolbelt = "^1.0.0" -pydantic-settings = "^2.5.2" -fastapi = {extras = ["all"], version = "^0.114.2"} -sqlalchemy = "^2.0.34" -fire = "^0.6.0" -python-multipart = "^0.0.9" - -[tool.poetry.group.dev.dependencies] -coverage = "*" -coveralls = "*" -docker = "*" -mimesis = "*" -openapi-spec-validator = "*" -pydocstyle = "==3.0.0" -pylint = "<2" -pytest = "*" -pytest-cov = "*" - -[tool.poetry.scripts] -gen_server = "arxiv.scripts:gen_server" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} +optional-dependencies = {dev = { file = ["requirements-dev.txt"] }} [tool.black] line-length = 88 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ab0e5b5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,35 @@ +astroid==1.6.6 +attrs==24.2.0 +certifi==2024.8.30 +charset-normalizer==3.3.2 +coverage==7.6.1 +coveralls==1.8.0 +docker==7.1.0 +docopt==0.6.2 +idna==3.10 +iniconfig==2.0.0 +isort==5.13.2 +jsonschema==4.23.0 +jsonschema-path==0.3.3 +jsonschema-specifications==2023.12.1 +lazy-object-proxy==1.10.0 +mccabe==0.7.0 +mimesis==18.0.0 +openapi-schema-validator==0.6.2 +openapi-spec-validator==0.7.1 +packaging==24.1 +pathable==0.4.3 +pluggy==1.5.0 +pydocstyle==3.0.0 +pylint==1.9.4 +pytest==8.3.3 +pytest-cov==5.0.0 +pyyaml==6.0.2 +referencing==0.35.1 +requests==2.32.3 +rfc3339-validator==0.1.4 +rpds-py==0.20.0 +six==1.16.0 +snowballstemmer==2.2.0 +urllib3==2.2.3 +wrapt==1.16.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..699f0ca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,65 @@ +annotated-types==0.7.0 +anyio==4.5.0 +arxiv-base @ git+https://github.com/arXiv/arxiv-base.git@5200e3d4bec9784b13d77849260f1e11842b977a +attrs==24.2.0 +backports-datetime-fromisoformat==2.0.2 +certifi==2024.8.30 +charset-normalizer==3.3.2 +click==8.1.7 +decorator==5.1.1 +dnspython==2.6.1 +email-validator==2.2.0 +fastapi==0.114.2 +fastapi-cli==0.0.5 +fire==0.5.0 +greenlet==3.1.0 +h11==0.14.0 +httpcore==1.0.5 +httptools==0.6.1 +httpx==0.27.2 +idna==3.10 +itsdangerous==2.2.0 +jinja2==3.1.4 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 +markdown-it-py==3.0.0 +markupsafe==2.1.5 +mdurl==0.1.2 +mimesis==18.0.0 +mypy==1.11.2 +mypy-extensions==1.0.0 +orjson==3.10.7 +py==1.11.0 +pydantic==2.9.2 +pydantic-core==2.23.4 +pydantic-extra-types==2.9.0 +pydantic-settings==2.5.2 +pygments==2.18.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-multipart==0.0.9 +pytz==2018.7 +pyyaml==6.0.2 +referencing==0.35.1 +requests==2.32.3 +requests-toolbelt==1.0.0 +retry==0.9.2 +rich==13.8.1 +rpds-py==0.20.0 +semver==3.0.2 +shellingham==1.5.4 +six==1.16.0 +sniffio==1.3.1 +sqlalchemy==2.0.35 +starlette==0.38.5 +termcolor==2.4.0 +typer==0.12.5 +typing-extensions==4.12.2 +ujson==5.10.0 +unidecode==1.3.8 +urllib3==2.2.3 +uvicorn==0.30.6 +uvloop==0.20.0 +validators==0.34.0 +watchfiles==0.24.0 +websockets==13.0.1 diff --git a/submit_ce/fastapi/implementations/legacy_implementation.py b/submit_ce/fastapi/implementations/legacy_implementation.py index 6a3df93..33db62c 100644 --- a/submit_ce/fastapi/implementations/legacy_implementation.py +++ b/submit_ce/fastapi/implementations/legacy_implementation.py @@ -1,6 +1,7 @@ import datetime import logging from typing import Dict, Union, Optional +from pydantic_settings import BaseSettings from arxiv.config import settings import arxiv.db @@ -15,7 +16,8 @@ from submit_ce.fastapi.api.default_api_base import BaseDefaultApi from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ AuthorshipDirect, AuthorshipProxy -from submit_ce.fastapi.config import Settings + + from submit_ce.fastapi.implementations import ImplementationConfig from submit_ce.file_store import SubmissionFileStore from submit_ce.file_store.legacy_file_store import LegacyFileStore @@ -205,7 +207,7 @@ async def get_service_status(self, impl_data: dict): return f"{self.__class__.__name__} impl_data: {impl_data}" -def setup(settings: Settings) -> None: +def setup(settings: BaseSettings) -> None: pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 89d96a0..b1c3425 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,14 @@ from fastapi import FastAPI from fastapi.testclient import TestClient +# to ensure we can import this due to confusing errors if it is missing. +import arxiv.db + +# to ensure we can import this due to confusing errors if deps are missing. +import submit_ce.fastapi.implementations.legacy_implementation + from submit_ce.fastapi.config import DEV_SQLITE_FILE -from tests.make_test_db import create_all_legacy_db +from .make_test_db import create_all_legacy_db From adc2921cafe31b642b9da1e94e4612c8df578095 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Mon, 23 Sep 2024 15:11:16 -0400 Subject: [PATCH 25/28] Adds setCategory --- submit_ce/fastapi/api/default_api.py | 24 +++- submit_ce/fastapi/api/default_api_base.py | 7 +- submit_ce/fastapi/api/models/__init__.py | 20 ++++ .../fastapi/api/models/events/__init__.py | 23 +++- .../implementations/legacy_implementation.py | 103 +++++++++++++++--- 5 files changed, 158 insertions(+), 19 deletions(-) diff --git a/submit_ce/fastapi/api/default_api.py b/submit_ce/fastapi/api/default_api.py index c1decb1..3ff1661 100644 --- a/submit_ce/fastapi/api/default_api.py +++ b/submit_ce/fastapi/api/default_api.py @@ -21,8 +21,9 @@ from submit_ce.fastapi.config import config from .default_api_base import BaseDefaultApi +from .models import CategoryChangeResult from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, AuthorshipDirect, \ - AuthorshipProxy + AuthorshipProxy, SetCategories from ..auth import get_user, get_client from ..implementations import ImplementationConfig @@ -143,14 +144,33 @@ async def file_post( return await implementation.file_post(impl_dep, user, client, submission_id, uploadFile) -# todo +@router.post( + "/submission/{submission_id}/setCategories", + tags=["submit"], +) +async def set_categories_post(set_categoires: SetCategories, + submission_id: str = Path(..., description="Id of the submission to set the categories for."), + impl_dep: dict = Depends(impl_depends), + user=userDep, client=clentDep + ) -> CategoryChangeResult: + """Set the categories for a submission. + + The categories will replace any categories already set on the submission.""" + return await implementation.set_categories_post(impl_dep, user, client, submission_id, set_categoires) """ /files get post head delete + /files/{path} get post head delete +process post + preview post get head delete +metadata get post head delete + +optional metadata get post head delete +finalize (aka submit) post """ @router.post( "/submission/{submission_id}/markDeposited", diff --git a/submit_ce/fastapi/api/default_api_base.py b/submit_ce/fastapi/api/default_api_base.py index 8e36c23..9da223f 100644 --- a/submit_ce/fastapi/api/default_api_base.py +++ b/submit_ce/fastapi/api/default_api_base.py @@ -5,8 +5,9 @@ from fastapi import UploadFile +from submit_ce.fastapi.api.models import CategoryChangeResult from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ - SetLicense + SetLicense, SetCategories from submit_ce.fastapi.api.models.agent import User, Client @@ -106,3 +107,7 @@ async def file_post(self, impl_dep: Dict, user: User, client: Client, submission The file can be a single file, a zip, or a tar.gz. Zip and tar.gz files will be unpacked. """ ... + + async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, + set_categoires: SetCategories) -> CategoryChangeResult: + pass diff --git a/submit_ce/fastapi/api/models/__init__.py b/submit_ce/fastapi/api/models/__init__.py index e69de29..bc9059a 100644 --- a/submit_ce/fastapi/api/models/__init__.py +++ b/submit_ce/fastapi/api/models/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Literal, List, Optional + +from arxiv.taxonomy.definitions import CATEGORIES_ACTIVE, CATEGORIES +from pydantic import BaseModel, Field + +ACTIVE_CATEGORY = Literal[tuple(cat.id for cat in CATEGORIES.values() if cat.is_active)] +ALL_CATEGORIES = Literal[tuple(cat.id for cat in CATEGORIES.values())] + +class CategoryChangeResult(BaseModel): + new_primary: Optional[ALL_CATEGORIES] = None + """The primary category before this change""" + old_primary: Optional[ALL_CATEGORIES] = None + """The primary category after this change""" + new_secondaries: List[ALL_CATEGORIES] = Field(default_factory=list) + """The secondaries after this change""" + old_secondaries: List[ALL_CATEGORIES] = Field(default_factory=list) + """The secondaries before this change""" + diff --git a/submit_ce/fastapi/api/models/events/__init__.py b/submit_ce/fastapi/api/models/events/__init__.py index 4811ced..8f54bea 100644 --- a/submit_ce/fastapi/api/models/events/__init__.py +++ b/submit_ce/fastapi/api/models/events/__init__.py @@ -1,10 +1,11 @@ from __future__ import annotations import pprint -from typing import Optional, Any, Dict, Literal +from typing import Optional, Any, Dict, Literal, List -from pydantic import BaseModel, AwareDatetime, StrictStr +from pydantic import BaseModel, AwareDatetime +from submit_ce.fastapi.api.models import ACTIVE_CATEGORY from submit_ce.fastapi.api.models.agent import User, Client @@ -73,6 +74,24 @@ class EventInfo(BaseModel): """ +class SetCategories(BaseModel): + primary_category: ACTIVE_CATEGORY + """The primary category of research that the submission is relevant to. + + A submission must have a primary category and there may be only one primary category for the submission.""" + + secondary_categories: List[ACTIVE_CATEGORY] + """Additional categories of research the submission is relevant to. + + This is only for use with new submissions. + + The order of these does not have any significance. + + There should not be duplications in this list. + + The primary category must not be on this list.""" + + class AgreedToPolicy(BaseModel): """ The sender of this request agrees to the statement in the agreement. diff --git a/submit_ce/fastapi/implementations/legacy_implementation.py b/submit_ce/fastapi/implementations/legacy_implementation.py index 33db62c..f84fb49 100644 --- a/submit_ce/fastapi/implementations/legacy_implementation.py +++ b/submit_ce/fastapi/implementations/legacy_implementation.py @@ -5,18 +5,18 @@ from arxiv.config import settings import arxiv.db -from arxiv.db.models import Submission, Document, configure_db_engine +from arxiv.db.models import Submission, Document, configure_db_engine, SubmissionCategory from fastapi import Depends, HTTPException, status, UploadFile from pydantic import ImportString from pydantic_settings import BaseSettings from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession, Session +from submit_ce.fastapi.api.models import CategoryChangeResult from submit_ce.fastapi.api.models.agent import User, Client from submit_ce.fastapi.api.default_api_base import BaseDefaultApi from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ - AuthorshipDirect, AuthorshipProxy - + AuthorshipDirect, AuthorshipProxy, SetCategories from submit_ce.fastapi.implementations import ImplementationConfig from submit_ce.file_store import SubmissionFileStore @@ -76,11 +76,17 @@ def legacy_depends(db=Depends(get_session)) -> dict: return {"session": db} def check_user_authorized(session: Session, user: User, client: Client, submision_id: str) -> None: - pass # TODO implement authorized check, use scopes from arxiv.auth? + # TODO implement authorized check, use scopes from arxiv.auth? + # TODO implement is_locked on submission + pass -def check_submission_exists(session: Session, submission_id: str) -> Submission: +def check_submission_exists(session: Session, submission_id: str, lock_row: bool = False) -> Submission: try: stmt = select(Submission).where(Submission.submission_id == int(submission_id)) + if lock_row: # row will be locked until .commit() use .flush() to get auto inc ids without unlocking + session.begin() + stmt = stmt.with_for_update() + submission = session.scalars(stmt).first() if not submission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, @@ -93,6 +99,15 @@ def check_submission_exists(session: Session, submission_id: str) -> Submission: class LegacySubmitImplementation(BaseDefaultApi): + """ + TODO write admin log on all changes + TODO success response objects (similar to modapi? {msg: success, updated_fields:[]}) + TODO failure to validate response objects (which field caused the problem?) + TODO Failure response object (general failure message) + TODO Later: edit token similar to modapi? + + + """ def __init__(self, store: Optional[SubmissionFileStore] = None): if store is None: #self.store = LegacyFileStore(root_dir=legacy_specific_settings.legacy_root_dir) @@ -110,6 +125,7 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio now = datetime.datetime.utcnow() submission = Submission(submitter_id=user.identifier, submitter_name=user.get_name(), + submitter_email=user.email, userinfo=0, agree_policy=0, viewed=0, @@ -124,7 +140,9 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio remote_host=client.remoteHost, type=started.submission_type, package="", + must_process=1, ) + if isinstance(started, StartedAlterExising): doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() if not doc: @@ -139,6 +157,9 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio session.commit() return str(submission.submission_id) + + # TODO need to do "userinfo" attestation + async def accept_policy_post(self, impl_data: Dict, user: User, client: Client, submission_id: str, agreement: AgreedToPolicy) -> object: @@ -176,24 +197,77 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien async def file_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, uploadFile: UploadFile): session: SqlalchemySession = impl_dep["session"] check_user_authorized(session, user, client, submission_id) - if legacy_specific_settings.legacy_serialize_file_operations and db_lock_capable(session): - session.begin() - lock_stmt = select(Submission).where(Submission.submission_id == int(submission_id)).with_for_update() - submission = session.scalar(lock_stmt) - if not submission: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Submission {submission_id} does not exist") - else: - submission = check_submission_exists(session, submission_id) - acceptable_types = ["application/gzip", "application/tar", "application/tar+gzip"] + submission = check_submission_exists(session, submission_id, + lock_row=legacy_specific_settings.legacy_serialize_file_operations) + acceptable_types = ["application/gzip", "application/tar", "application/tar+gzip"] if uploadFile.content_type in acceptable_types: checksum = await self.store.store_source_package(submission.submission_id, uploadFile) + # TODO db changes for upload: source_format + # TODO db changes for upload: source_size + # TODO db changes for upload: package? + else: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File content type must be one of {acceptable_types}"\ " but it was {uploadFile.content_type}." ) + async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, + data: SetCategories): + session: SqlalchemySession = impl_dep["session"] + check_user_authorized(session, user, client, submission_id) + """Categories on legacy submissions are stored in the arXiv_submissions_category table.""" + submission = check_submission_exists(session, submission_id) + + # similar to code in modapi routes.py + stmt = select(SubmissionCategory).where(SubmissionCategory.submission_id == submission.submission_id) + early_rows = session.scalars(stmt).all() + early_primary = next((c.category for c in early_rows if c.is_primary), None) + early_categories = set(c.category for c in early_rows) + + new_primary = data.primary_category + new_secondaries = set(data.secondary_categories) + new_categories = new_secondaries.copy() + if new_primary: + new_categories.add(new_primary) + + add_categories = new_categories - early_categories + del_categories = early_categories - new_categories + + updates = set() + for cat in add_categories: + if cat == new_primary: + updates.add("primary") + else: + updates.add("secondary") + session.add(SubmissionCategory( + submission_id=submission.submission_id, + category=cat, + is_primary=cat == new_primary, + is_published=0, + )) + + for cat in del_categories: + if cat == new_primary: + updates.add("primary") + else: + updates.add("secondary") + row = [row for row in early_rows if row.category == cat] + session.delete(row[0]) + + #self.admin_log(session, user, f"Edited: {','.join(updates)}", command="edit metadata") + result = CategoryChangeResult() + eps = set() if not early_primary else set([early_primary]) + if early_primary != new_primary: + result.old_primary = early_primary + result.new_primary = new_primary + if new_secondaries != early_categories - eps: + result.old_secondaries = list(early_categories - eps) + result.new_secondaries = list(new_categories) + return result + + async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass @@ -203,6 +277,7 @@ async def mark_processing_for_deposit_post(self, impl_data: Dict, user: User, cl async def unmark_processing_for_deposit_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass + async def get_service_status(self, impl_data: dict): return f"{self.__class__.__name__} impl_data: {impl_data}" From 91882a03a165fb1aebcc0dd8200f2e3525c242e9 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Mon, 23 Sep 2024 19:43:24 -0400 Subject: [PATCH 26/28] Adds a test that runs throug the submit --- .idea/.gitignore | 8 - submit_ce/fastapi/api/default_api.py | 13 +- submit_ce/fastapi/api/default_api_base.py | 9 +- .../fastapi/api/models/events/__init__.py | 40 ++++- .../implementations/legacy_implementation.py | 63 ++++++-- tests/test_default_api.py | 146 +++++++----------- 6 files changed, 167 insertions(+), 112 deletions(-) delete mode 100644 .idea/.gitignore diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/submit_ce/fastapi/api/default_api.py b/submit_ce/fastapi/api/default_api.py index 3ff1661..618e6ef 100644 --- a/submit_ce/fastapi/api/default_api.py +++ b/submit_ce/fastapi/api/default_api.py @@ -19,11 +19,10 @@ from fastapi.responses import PlainTextResponse from submit_ce.fastapi.config import config - from .default_api_base import BaseDefaultApi from .models import CategoryChangeResult from .models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, AuthorshipDirect, \ - AuthorshipProxy, SetCategories + AuthorshipProxy, SetCategories, SetMetadata from ..auth import get_user, get_client from ..implementations import ImplementationConfig @@ -157,6 +156,16 @@ async def set_categories_post(set_categoires: SetCategories, The categories will replace any categories already set on the submission.""" return await implementation.set_categories_post(impl_dep, user, client, submission_id, set_categoires) + +@router.post( + "/submission/{submission_id}/setMetadata", + tags=["submit"], +) +async def set_metadata_post(metadata: Union[SetMetadata], + submission_id: str = Path(..., description="Id of the submission to set the metadata for."), + impl_dep: dict = Depends(impl_depends), + user=userDep, client=clentDep) -> str: + return await implementation.set_metadata_post(impl_dep, user, client, submission_id, metadata) """ /files get post head delete diff --git a/submit_ce/fastapi/api/default_api_base.py b/submit_ce/fastapi/api/default_api_base.py index 9da223f..75be944 100644 --- a/submit_ce/fastapi/api/default_api_base.py +++ b/submit_ce/fastapi/api/default_api_base.py @@ -1,14 +1,13 @@ # coding: utf-8 from abc import ABC, abstractmethod - from typing import ClassVar, Dict, List, Tuple, Union # noqa: F401 from fastapi import UploadFile from submit_ce.fastapi.api.models import CategoryChangeResult -from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ - SetLicense, SetCategories from submit_ce.fastapi.api.models.agent import User, Client +from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, AuthorshipDirect, AuthorshipProxy, \ + SetLicense, SetCategories, SetMetadata class BaseDefaultApi(ABC): @@ -111,3 +110,7 @@ async def file_post(self, impl_dep: Dict, user: User, client: Client, submission async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, set_categoires: SetCategories) -> CategoryChangeResult: pass + + async def set_metadata_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, + metadata: Union[SetMetadata]): + pass diff --git a/submit_ce/fastapi/api/models/events/__init__.py b/submit_ce/fastapi/api/models/events/__init__.py index 8f54bea..863d1b4 100644 --- a/submit_ce/fastapi/api/models/events/__init__.py +++ b/submit_ce/fastapi/api/models/events/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import pprint -from typing import Optional, Any, Dict, Literal, List +from typing import Optional, Any, Dict, Literal, List, Union from pydantic import BaseModel, AwareDatetime @@ -114,6 +114,44 @@ class SetLicense(BaseModel): ] """The license the sender offers to the arxiv users for the submitted items.""" +class SetMetadata(BaseModel): + title: Optional[str] = None + authors: Optional[str] = None + comments: Optional[str] = None + abstract: Optional[str] = None + report_num: Optional[int] = None + msc_class: Optional[str] = None + acm_class: Optional[str] = None + journal_ref: Optional[str] = None + doi: Optional[str] = None + + +class AuthorName(BaseModel): + """A speculative more detailed author name record.""" + + author_list_name: Optional[str] = None + """Name as it should apper in author list.""" + + full_name: Optional[str] = None + """Full name of the author.""" + + language: Optional[str] = None + """Language of the full name.""" + + orcid: Optional[str] = None + """orcid.org identifier of author""" + + +class SetAuthorsMetadata(BaseModel): + authors: List[Union[str, AuthorName]] = None + + +class SetOrganizationMetadata(BaseModel): + """A speculative metadata record to describe funding organizations related to the paper.""" + + organizations: List[str] = None + """RORs of funding organizations.""" + class AuthorshipDirect(BaseModel): """ diff --git a/submit_ce/fastapi/implementations/legacy_implementation.py b/submit_ce/fastapi/implementations/legacy_implementation.py index f84fb49..fca9b5d 100644 --- a/submit_ce/fastapi/implementations/legacy_implementation.py +++ b/submit_ce/fastapi/implementations/legacy_implementation.py @@ -1,23 +1,20 @@ import datetime import logging from typing import Dict, Union, Optional -from pydantic_settings import BaseSettings -from arxiv.config import settings import arxiv.db +from arxiv.config import settings from arxiv.db.models import Submission, Document, configure_db_engine, SubmissionCategory from fastapi import Depends, HTTPException, status, UploadFile -from pydantic import ImportString from pydantic_settings import BaseSettings from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session as SqlalchemySession, Session +from submit_ce.fastapi.api.default_api_base import BaseDefaultApi from submit_ce.fastapi.api.models import CategoryChangeResult from submit_ce.fastapi.api.models.agent import User, Client -from submit_ce.fastapi.api.default_api_base import BaseDefaultApi from submit_ce.fastapi.api.models.events import AgreedToPolicy, StartedNew, StartedAlterExising, SetLicense, \ - AuthorshipDirect, AuthorshipProxy, SetCategories - + AuthorshipDirect, AuthorshipProxy, SetCategories, SetMetadata from submit_ce.fastapi.implementations import ImplementationConfig from submit_ce.file_store import SubmissionFileStore from submit_ce.file_store.legacy_file_store import LegacyFileStore @@ -54,7 +51,7 @@ def get_session() -> SqlalchemySession: if not _setup: if 'sqlite' in settings.CLASSIC_DB_URI: args = {"check_same_thread": False} - else: + else: # pragma: no cover args = {} engine = create_engine(settings.CLASSIC_DB_URI, echo=settings.ECHO_SQL, connect_args=args) arxiv.db.session_factory = sessionmaker(autoflush=False, bind=engine) @@ -144,7 +141,7 @@ async def start(self, impl_data: Dict, user: User, client: Client, started: Unio ) if isinstance(started, StartedAlterExising): - doc = session.scalars(select(Document).where(paper_id=started.paperid)).first() + doc = session.scalars(select(Document).where(Document.paper_id==started.paperid)).first() if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="Existing paper not found.") elif doc.submitter_id != user.identifier: @@ -193,6 +190,7 @@ async def assert_authorship_post(self, impl_dep: Dict, user: User, client: Clien submission.is_author=0 submission.proxy=authorship.proxy session.commit() + return "success" async def file_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, uploadFile: UploadFile): session: SqlalchemySession = impl_dep["session"] @@ -217,7 +215,6 @@ async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, data: SetCategories): session: SqlalchemySession = impl_dep["session"] check_user_authorized(session, user, client, submission_id) - """Categories on legacy submissions are stored in the arXiv_submissions_category table.""" submission = check_submission_exists(session, submission_id) # similar to code in modapi routes.py @@ -256,7 +253,9 @@ async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, row = [row for row in early_rows if row.category == cat] session.delete(row[0]) - #self.admin_log(session, user, f"Edited: {','.join(updates)}", command="edit metadata") + # if updates: + # self.admin_log(session, user, f"Edited: {','.join(updates)}", command="edit metadata") + result = CategoryChangeResult() eps = set() if not early_primary else set([early_primary]) if early_primary != new_primary: @@ -267,6 +266,50 @@ async def set_categories_post(self, impl_dep: Dict, user: User, client: Client, result.new_secondaries = list(new_categories) return result + async def set_metadata_post(self, impl_dep: Dict, user: User, client: Client, submission_id: str, + metadata: Union[SetMetadata]): + session: SqlalchemySession = impl_dep["session"] + check_user_authorized(session, user, client, submission_id) + submission = check_submission_exists(session, submission_id) + update = [] + # TODO add checks + if metadata.abstract != submission.abstract: + submission.abstract = metadata.abstract + update.append("abstract") + if metadata.authors != submission.authors: + submission.authors = metadata.authors + update.append("authors") + if metadata.title != submission.title: + submission.title = metadata.title + update.append("title") + if metadata.comments != submission.comments: + submission.comments = metadata.comments + update.append("comments") + if metadata.acm_class != submission.acm_class: + submission.acm_class = metadata.acm_class + update.append("acm_class") + if metadata.msc_class != submission.msc_class: + submission.msc_class = metadata.msc_class + update.append("msc_class") + if metadata.report_num != submission.report_num: + submission.report_num = metadata.report_num + update.append("report_num") + if metadata.journal_ref != submission.journal_ref: + submission.journal_ref = metadata.journal_ref + update.append("journal_ref") + if metadata.doi != submission.doi: + submission.doi = metadata.doi + update.append("doi") + + """Why is does it let blank fields in metadata? + Because those whill be handled by workflows and conditions. + (Or folks will tell us "absolutely no partial metadata! and we'll change this)""" + + if update: + # TODO Write admin_log + session.commit() + + return ",".join(update) async def mark_deposited_post(self, impl_data: Dict, user: User, client: Client, submission_id: str) -> None: pass diff --git a/tests/test_default_api.py b/tests/test_default_api.py index 6e541ca..9dcb1e7 100644 --- a/tests/test_default_api.py +++ b/tests/test_default_api.py @@ -12,25 +12,6 @@ def test_get_service_status(client: TestClient): assert response.status_code == 200 -def test_get_submission(client: TestClient): - """Test case for get_submission - - - """ - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "GET", - # "/{submission_id}".format(submission_id='submission_id_example'), - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 - - def test_start(client: TestClient): """Test case for begin.""" headers = { } @@ -47,6 +28,14 @@ def test_start(client: TestClient): assert str(data['submission_id']) == sid +def test_start_alter(client: TestClient): + response = client.request("POST", "/v1/start", headers={}, + json={"submission_type":"replacement", + "paperid": "totally_fake_paperid"}) + assert response.status_code == 404 + + + def test_submission_id_accept_policy_post(client: TestClient): """Test case for submission_id_accept_policy_post.""" headers = {} @@ -155,79 +144,60 @@ def test_invalid_license(client: TestClient, invalid_license:str): json={"license_uri": invalid_license}, headers=headers) assert response.status_code == 422 +def test_basic_submission(client: TestClient): + headers={} + response = client.request("POST", "/v1/start", headers=headers, + json={"submission_type": "new"}) + assert response.status_code == 200 + sid = response.text + assert sid is not None and '"' not in sid -def test_submission_id_deposit_packet_packet_format_get(client: TestClient): - """Test case for submission_id_deposit_packet_packet_format_get - - - """ - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "GET", - # "/{submission_id}/deposit_packet/{packet_format}".format(submission_id='submission_id_example', packet_format='packet_format_example'), - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 - - -def test_submission_id_deposited_post(client: TestClient): - """Test case for submission_id_deposited_post - - - """ - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "POST", - # "/{submission_id}/Deposited".format(submission_id='submission_id_example'), - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 - - -def test_submission_id_mark_processing_for_deposit_post(client: TestClient): - """Test case for submission_id_mark_processing_for_deposit_post - - - """ - - headers = { - } - # uncomment below to make a request - #response = client.request( - # "POST", - # "/{submission_id}/markProcessingForDeposit".format(submission_id='submission_id_example'), - # headers=headers, - #) - - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 + response = client.request( + "POST", + f"/v1/submission/{sid}/acceptPolicy", + headers=headers, + json={"accepted_policy_id":3}) + assert response.status_code == 200 + response = client.request("POST", f"/v1/submission/{sid}/setLicense", + json={"license_uri": "http://arxiv.org/licenses/nonexclusive-distrib/1.0/"}, + headers=headers) + assert response.status_code == 200 -def test_submission_id_unmark_processing_for_deposit_post(client: TestClient): - """Test case for submission_id_unmark_processing_for_deposit_post + response = client.request("POST", f"/v1/submission/{sid}/assertAuthorship", + json={"i_am_author": True}, + headers=headers) + assert response.status_code == 200 - - """ + response = client.request("POST", f"/v1/submission/{sid}/setCategories", + json={"primary_category": "astro-ph.EP", "secondary_categories": ["astro-ph.GA"]}, + headers=headers) + assert response.status_code == 200 or response.content == "" - headers = { - } - # uncomment below to make a request - #response = client.request( - # "POST", - # "/{submission_id}/unmarkProcessingForDeposit".format(submission_id='submission_id_example'), - # headers=headers, - #) + response = client.request("POST", f"/v1/submission/{sid}/setCategories", + json={"primary_category": "astro-ph.EP", "secondary_categories": []}, + headers=headers) + assert response.status_code == 200 or response.content == "" + + response = client.request("POST", f"/v1/submission/{sid}/setMetadata", + json={ + "title": "fake title that should be good enough", + "abstract": "fake abstract that should be good enough", + "authors": "Smith, Bob", + "comments": "totally good" + }, + headers=headers) + assert response.status_code == 200 - # uncomment below to assert the status code of the HTTP response - #assert response.status_code == 200 + response = client.request("POST", f"/v1/submission/{sid}/setMetadata", + json={ + "msc_class": "bogus class", + "acm_class": "2.34", + "report_num": "24333", + "doi": "totally_fake_doi", + "journal_ref": "also totally fake jref", + }, + headers=headers) + + assert response.status_code == 200 or response.text == "" From 47275fd2a21adb6451d457184b45f92b00b26a37 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Mon, 23 Sep 2024 19:59:53 -0400 Subject: [PATCH 27/28] removing some NG code --- submit/.python-version | 1 - submit/__init__.py | 1 - submit/config.py | 386 --------- submit/controllers/__init__.py | 0 submit/controllers/api/__init__.py | 0 submit/controllers/ui/__init__.py | 55 -- submit/controllers/ui/cross.py | 211 ----- submit/controllers/ui/delete.py | 133 --- submit/controllers/ui/jref.py | 150 ---- submit/controllers/ui/new/__init__.py | 0 submit/controllers/ui/new/authorship.py | 98 --- submit/controllers/ui/new/classification.py | 212 ----- submit/controllers/ui/new/create.py | 105 --- submit/controllers/ui/new/final.py | 83 -- submit/controllers/ui/new/license.py | 76 -- submit/controllers/ui/new/metadata.py | 247 ------ submit/controllers/ui/new/policy.py | 68 -- submit/controllers/ui/new/process.py | 257 ------ submit/controllers/ui/new/reasons.py | 37 - .../ui/new/tests/test_authorship.py | 152 ---- .../ui/new/tests/test_classification.py | 268 ------ .../controllers/ui/new/tests/test_license.py | 165 ---- .../controllers/ui/new/tests/test_metadata.py | 405 ---------- .../controllers/ui/new/tests/test_policy.py | 176 ---- .../controllers/ui/new/tests/test_primary.py | 155 ---- .../controllers/ui/new/tests/test_unsubmit.py | 101 --- .../controllers/ui/new/tests/test_upload.py | 334 -------- .../ui/new/tests/test_verify_user.py | 128 --- submit/controllers/ui/new/unsubmit.py | 59 -- submit/controllers/ui/new/upload.py | 548 ------------- submit/controllers/ui/new/upload_delete.py | 251 ------ submit/controllers/ui/new/verify_user.py | 82 -- submit/controllers/ui/tests/__init__.py | 1 - submit/controllers/ui/tests/test_jref.py | 147 ---- submit/controllers/ui/util.py | 173 ---- submit/controllers/ui/withdraw.py | 88 -- submit/db.sqlite | Bin 327680 -> 0 bytes submit/factory.py | 101 --- submit/filters/__init__.py | 141 ---- submit/filters/tests/test_tex_filters.py | 143 ---- submit/filters/tex_filters.py | 548 ------------- submit/integration/README.md | 19 - submit/integration/__init__.py | 0 submit/integration/test_integration.py | 322 -------- submit/integration/upload2.tar.gz | Bin 27805 -> 0 bytes submit/routes/__init__.py | 3 - submit/routes/api/__init__.py | 0 submit/routes/auth.py | 24 - submit/routes/ui/__init__.py | 1 - submit/routes/ui/flow_control.py | 290 ------- submit/routes/ui/ui.py | 558 ------------- submit/services/__init__.py | 2 - submit/static/css/manage_submissions.css | 89 -- submit/static/css/manage_submissions.css.map | 7 - submit/static/css/submit.css | 551 ------------- submit/static/css/submit.css.map | 1 - .../images/github_issues_search_box.png | Bin 17739 -> 0 bytes submit/static/js/authorship.js | 20 - submit/static/js/filewidget.js | 20 - submit/static/sass/manage_submissions.sass | 112 --- submit/static/sass/submit.sass | 440 ---------- submit/templates/submit/add_metadata.html | 119 --- .../submit/add_optional_metadata.html | 165 ---- submit/templates/submit/admin_macros.html | 76 -- submit/templates/submit/authorship.html | 66 -- submit/templates/submit/base.html | 61 -- submit/templates/submit/classification.html | 94 --- .../submit/confirm_cancel_request.html | 36 - submit/templates/submit/confirm_delete.html | 30 - .../templates/submit/confirm_delete_all.html | 27 - .../submit/confirm_delete_submission.html | 40 - submit/templates/submit/confirm_submit.html | 26 - submit/templates/submit/confirm_unsubmit.html | 59 -- submit/templates/submit/cross_list.html | 89 -- submit/templates/submit/error_messages.html | 104 --- submit/templates/submit/file_process.html | 281 ------- submit/templates/submit/file_upload.html | 178 ---- submit/templates/submit/final_preview.html | 92 --- submit/templates/submit/jref.html | 161 ---- submit/templates/submit/license.html | 72 -- .../templates/submit/manage_submissions.html | 175 ---- submit/templates/submit/policy.html | 78 -- submit/templates/submit/replace.html | 36 - .../templates/submit/request_cross_list.html | 121 --- submit/templates/submit/status.html | 24 - submit/templates/submit/submit_macros.html | 41 - submit/templates/submit/testalerts.html | 1 - submit/templates/submit/tex-log-test.html | 84 -- submit/templates/submit/verify_user.html | 97 --- submit/templates/submit/withdraw.html | 124 --- submit/tests/__init__.py | 1 - submit/tests/csrf_util.py | 24 - submit/tests/mock_filemanager.py | 109 --- submit/tests/test_domain.py | 65 -- submit/tests/test_workflow.py | 762 ------------------ submit/util.py | 157 ---- submit/workflow/__init__.py | 150 ---- submit/workflow/conditions.py | 72 -- submit/workflow/processor.py | 81 -- submit/workflow/stages.py | 161 ---- submit/workflow/test_new_submission.py | 258 ------ 101 files changed, 13142 deletions(-) delete mode 100644 submit/.python-version delete mode 100644 submit/__init__.py delete mode 100644 submit/config.py delete mode 100644 submit/controllers/__init__.py delete mode 100644 submit/controllers/api/__init__.py delete mode 100644 submit/controllers/ui/__init__.py delete mode 100644 submit/controllers/ui/cross.py delete mode 100644 submit/controllers/ui/delete.py delete mode 100644 submit/controllers/ui/jref.py delete mode 100644 submit/controllers/ui/new/__init__.py delete mode 100644 submit/controllers/ui/new/authorship.py delete mode 100644 submit/controllers/ui/new/classification.py delete mode 100644 submit/controllers/ui/new/create.py delete mode 100644 submit/controllers/ui/new/final.py delete mode 100644 submit/controllers/ui/new/license.py delete mode 100644 submit/controllers/ui/new/metadata.py delete mode 100644 submit/controllers/ui/new/policy.py delete mode 100644 submit/controllers/ui/new/process.py delete mode 100644 submit/controllers/ui/new/reasons.py delete mode 100644 submit/controllers/ui/new/tests/test_authorship.py delete mode 100644 submit/controllers/ui/new/tests/test_classification.py delete mode 100644 submit/controllers/ui/new/tests/test_license.py delete mode 100644 submit/controllers/ui/new/tests/test_metadata.py delete mode 100644 submit/controllers/ui/new/tests/test_policy.py delete mode 100644 submit/controllers/ui/new/tests/test_primary.py delete mode 100644 submit/controllers/ui/new/tests/test_unsubmit.py delete mode 100644 submit/controllers/ui/new/tests/test_upload.py delete mode 100644 submit/controllers/ui/new/tests/test_verify_user.py delete mode 100644 submit/controllers/ui/new/unsubmit.py delete mode 100644 submit/controllers/ui/new/upload.py delete mode 100644 submit/controllers/ui/new/upload_delete.py delete mode 100644 submit/controllers/ui/new/verify_user.py delete mode 100644 submit/controllers/ui/tests/__init__.py delete mode 100644 submit/controllers/ui/tests/test_jref.py delete mode 100644 submit/controllers/ui/util.py delete mode 100644 submit/controllers/ui/withdraw.py delete mode 100644 submit/db.sqlite delete mode 100644 submit/factory.py delete mode 100644 submit/filters/__init__.py delete mode 100644 submit/filters/tests/test_tex_filters.py delete mode 100644 submit/filters/tex_filters.py delete mode 100644 submit/integration/README.md delete mode 100644 submit/integration/__init__.py delete mode 100644 submit/integration/test_integration.py delete mode 100644 submit/integration/upload2.tar.gz delete mode 100644 submit/routes/__init__.py delete mode 100644 submit/routes/api/__init__.py delete mode 100644 submit/routes/auth.py delete mode 100644 submit/routes/ui/__init__.py delete mode 100644 submit/routes/ui/flow_control.py delete mode 100644 submit/routes/ui/ui.py delete mode 100644 submit/services/__init__.py delete mode 100644 submit/static/css/manage_submissions.css delete mode 100644 submit/static/css/manage_submissions.css.map delete mode 100644 submit/static/css/submit.css delete mode 100644 submit/static/css/submit.css.map delete mode 100644 submit/static/images/github_issues_search_box.png delete mode 100644 submit/static/js/authorship.js delete mode 100644 submit/static/js/filewidget.js delete mode 100644 submit/static/sass/manage_submissions.sass delete mode 100644 submit/static/sass/submit.sass delete mode 100644 submit/templates/submit/add_metadata.html delete mode 100644 submit/templates/submit/add_optional_metadata.html delete mode 100644 submit/templates/submit/admin_macros.html delete mode 100644 submit/templates/submit/authorship.html delete mode 100644 submit/templates/submit/base.html delete mode 100644 submit/templates/submit/classification.html delete mode 100644 submit/templates/submit/confirm_cancel_request.html delete mode 100644 submit/templates/submit/confirm_delete.html delete mode 100644 submit/templates/submit/confirm_delete_all.html delete mode 100644 submit/templates/submit/confirm_delete_submission.html delete mode 100644 submit/templates/submit/confirm_submit.html delete mode 100644 submit/templates/submit/confirm_unsubmit.html delete mode 100644 submit/templates/submit/cross_list.html delete mode 100644 submit/templates/submit/error_messages.html delete mode 100644 submit/templates/submit/file_process.html delete mode 100644 submit/templates/submit/file_upload.html delete mode 100644 submit/templates/submit/final_preview.html delete mode 100644 submit/templates/submit/jref.html delete mode 100644 submit/templates/submit/license.html delete mode 100644 submit/templates/submit/manage_submissions.html delete mode 100644 submit/templates/submit/policy.html delete mode 100644 submit/templates/submit/replace.html delete mode 100644 submit/templates/submit/request_cross_list.html delete mode 100644 submit/templates/submit/status.html delete mode 100644 submit/templates/submit/submit_macros.html delete mode 100644 submit/templates/submit/testalerts.html delete mode 100644 submit/templates/submit/tex-log-test.html delete mode 100644 submit/templates/submit/verify_user.html delete mode 100644 submit/templates/submit/withdraw.html delete mode 100644 submit/tests/__init__.py delete mode 100644 submit/tests/csrf_util.py delete mode 100644 submit/tests/mock_filemanager.py delete mode 100644 submit/tests/test_domain.py delete mode 100644 submit/tests/test_workflow.py delete mode 100644 submit/util.py delete mode 100644 submit/workflow/__init__.py delete mode 100644 submit/workflow/conditions.py delete mode 100644 submit/workflow/processor.py delete mode 100644 submit/workflow/stages.py delete mode 100644 submit/workflow/test_new_submission.py diff --git a/submit/.python-version b/submit/.python-version deleted file mode 100644 index b78aae2..0000000 --- a/submit/.python-version +++ /dev/null @@ -1 +0,0 @@ -submission-ui diff --git a/submit/__init__.py b/submit/__init__.py deleted file mode 100644 index 6d49e6c..0000000 --- a/submit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Main submit UI.""" diff --git a/submit/config.py b/submit/config.py deleted file mode 100644 index dd0c9ee..0000000 --- a/submit/config.py +++ /dev/null @@ -1,386 +0,0 @@ -f""" -Flask configuration. - -Docstrings are from the `Flask configuration documentation -`_. -""" -from typing import Optional -import warnings -from os import environ - -APP_VERSION = "0.1.1-alpha" -"""The current version of this application.""" - -NAMESPACE = environ.get('NAMESPACE') -"""Namespace in which this service is deployed; to qualify keys for secrets.""" - -APPLICATION_ROOT = environ.get('APPLICATION_ROOT', '/') -"""Path where application is deployed.""" - -SITE_URL_PREFIX = environ.get('APPLICATION_ROOT', '/') - -# RELATIVE_STATIC_PATHS = True -RELATIVE_STATIC_PREFIX = environ.get('APPLICATION_ROOT', '') - -LOGLEVEL = int(environ.get('LOGLEVEL', '20')) -""" -Logging verbosity. - -See `https://docs.python.org/3/library/logging.html#levels`_. -""" - -JWT_SECRET = environ.get('JWT_SECRET') -"""Secret key for signing + verifying authentication JWTs.""" - -CSRF_SECRET = environ.get('FLASK_SECRET', 'csrfbarsecret') -"""Secret used for generating CSRF tokens.""" - -if not JWT_SECRET: - warnings.warn('JWT_SECRET is not set; authn/z may not work correctly!') - - -WAIT_FOR_SERVICES = bool(int(environ.get('WAIT_FOR_SERVICES', '0'))) -"""Disable/enable waiting for upstream services to be available on startup.""" -if not WAIT_FOR_SERVICES: - warnings.warn('Awaiting upstream services is disabled; this should' - ' probably be enabled in production.') - -WAIT_ON_STARTUP = int(environ.get('WAIT_ON_STARTUP', '0')) -"""Number of seconds to wait before checking upstream services on startup.""" - -ENABLE_CALLBACKS = bool(int(environ.get('ENABLE_CALLBACKS', '1'))) -"""Enable/disable the :func:`Event.bind` feature.""" - -SESSION_COOKIE_NAME = 'submission_ui_session' -"""Cookie used to store ui-app-related information.""" - - -# --- FLASK CONFIGURATION --- - -DEBUG = bool(int(environ.get('DEBUG', '0'))) -"""enable/disable debug mode""" - -TESTING = bool(int(environ.get('TESTING', '0'))) -"""enable/disable testing mode""" - -SECRET_KEY = environ.get('FLASK_SECRET', 'fooflasksecret') -"""Flask secret key.""" - -PROPAGATE_EXCEPTIONS = \ - True if bool(int(environ.get('PROPAGATE_EXCEPTIONS', '0'))) else None -""" -explicitly enable or disable the propagation of exceptions. If not set or -explicitly set to None this is implicitly true if either TESTING or DEBUG is -true. -""" - -PRESERVE_CONTEXT_ON_EXCEPTION: Optional[bool] = None -""" -By default if the application is in debug mode the request context is not -popped on exceptions to enable debuggers to introspect the data. This can be -disabled by this key. You can also use this setting to force-enable it for non -debug execution which might be useful to debug production applications (but -also very risky). -""" -if bool(int(environ.get('PRESERVE_CONTEXT_ON_EXCEPTION', '0'))): - PRESERVE_CONTEXT_ON_EXCEPTION = True - - -USE_X_SENDFILE = bool(int(environ.get('USE_X_SENDFILE', '0'))) -"""Enable/disable x-sendfile""" - -LOGGER_NAME = environ.get('LOGGER_NAME', 'search') -"""The name of the logger.""" - -LOGGER_HANDLER_POLICY = environ.get('LOGGER_HANDLER_POLICY', 'debug') -""" -the policy of the default logging handler. The default is 'always' which means -that the default logging handler is always active. 'debug' will only activate -logging in debug mode, 'production' will only log in production and 'never' -disables it entirely. -""" - -SERVER_NAME = None # "foohost:8000" #environ.get('SERVER_NAME', None) -""" -the name and port number of the server. Required for subdomain support -(e.g.: 'myapp.dev:5000') Note that localhost does not support subdomains so -setting this to 'localhost' does not help. Setting a SERVER_NAME also by -default enables URL generation without a request context but with an -application context. -""" - - -# --- DATABASE CONFIGURATION --- - -CLASSIC_DATABASE_URI = environ.get('CLASSIC_DATABASE_URI', 'sqlite:///') -"""Full database URI for the classic system.""" - -SQLALCHEMY_DATABASE_URI = CLASSIC_DATABASE_URI -"""Full database URI for the classic system.""" - -SQLALCHEMY_TRACK_MODIFICATIONS = False -"""Track modifications feature should always be disabled.""" - -# Integration with the preview service. -PREVIEW_HOST = environ.get('PREVIEW_SERVICE_HOST', 'localhost') -"""Hostname or address of the preview service.""" - -PREVIEW_PORT = environ.get('PREVIEW_SERVICE_PORT', '8000') -"""Port for the preview service.""" - -PREVIEW_PROTO = environ.get( - f'PREVIEW_PORT_{PREVIEW_PORT}_PROTO', - environ.get('PREVIEW_PROTO', 'http') -) -"""Protocol for the preview service.""" - -PREVIEW_PATH = environ.get('PREVIEW_PATH', '') -"""Path at which the preview service is deployed.""" - -PREVIEW_ENDPOINT = environ.get( - 'PREVIEW_ENDPOINT', - '%s://%s:%s/%s' % (PREVIEW_PROTO, PREVIEW_HOST, PREVIEW_PORT, PREVIEW_PATH) -) -""" -Full URL to the root preview service API endpoint. - -If not explicitly provided, this is composed from :const:`PREVIEW_HOST`, -:const:`PREVIEW_PORT`, :const:`PREVIEW_PROTO`, -and :const:`PREVIEW_PATH`. -""" - -PREVIEW_VERIFY = bool(int(environ.get('PREVIEW_VERIFY', '0'))) -"""Enable/disable SSL certificate verification for preview service.""" - -PREVIEW_STATUS_TIMEOUT = float(environ.get('PREVIEW_STATUS_TIMEOUT', 1.0)) - -if PREVIEW_PROTO == 'https' and not PREVIEW_VERIFY: - warnings.warn('Certificate verification for preview service is disabled;' - ' this should not be disabled in production.') - - -# Integration with the file manager service. -FILEMANAGER_HOST = environ.get('FILEMANAGER_SERVICE_HOST', 'arxiv.org') -"""Hostname or addreess of the filemanager service.""" - -FILEMANAGER_PORT = environ.get('FILEMANAGER_SERVICE_PORT', '443') -"""Port for the filemanager service.""" - -FILEMANAGER_PROTO = environ.get(f'FILEMANAGER_PORT_{FILEMANAGER_PORT}_PROTO', - environ.get('FILEMANAGER_PROTO', 'https')) -"""Protocol for the filemanager service.""" - -FILEMANAGER_PATH = environ.get('FILEMANAGER_PATH', '').lstrip('/') -"""Path at which the filemanager service is deployed.""" - -FILEMANAGER_ENDPOINT = environ.get( - 'FILEMANAGER_ENDPOINT', - '%s://%s:%s/%s' % (FILEMANAGER_PROTO, FILEMANAGER_HOST, - FILEMANAGER_PORT, FILEMANAGER_PATH) -) -""" -Full URL to the root filemanager service API endpoint. - -If not explicitly provided, this is composed from :const:`FILEMANAGER_HOST`, -:const:`FILEMANAGER_PORT`, :const:`FILEMANAGER_PROTO`, and -:const:`FILEMANAGER_PATH`. -""" - -FILEMANAGER_VERIFY = bool(int(environ.get('FILEMANAGER_VERIFY', '1'))) -"""Enable/disable SSL certificate verification for filemanager service.""" - -FILEMANAGER_STATUS_ENDPOINT = environ.get('FILEMANAGER_STATUS_ENDPOINT', - 'status') -"""Path to the file manager service status endpoint.""" - -FILEMANAGER_STATUS_TIMEOUT \ - = float(environ.get('FILEMANAGER_STATUS_TIMEOUT', 1.0)) - -if FILEMANAGER_PROTO == 'https' and not FILEMANAGER_VERIFY: - warnings.warn('Certificate verification for filemanager is disabled; this' - ' should not be disabled in production.') - - -# Integration with the compiler service. -COMPILER_HOST = environ.get('COMPILER_SERVICE_HOST', 'arxiv.org') -"""Hostname or addreess of the compiler service.""" - -COMPILER_PORT = environ.get('COMPILER_SERVICE_PORT', '443') -"""Port for the compiler service.""" - -COMPILER_PROTO = environ.get(f'COMPILER_PORT_{COMPILER_PORT}_PROTO', - environ.get('COMPILER_PROTO', 'https')) -"""Protocol for the compiler service.""" - -COMPILER_PATH = environ.get('COMPILER_PATH', '') -"""Path at which the compiler service is deployed.""" - -COMPILER_ENDPOINT = environ.get( - 'COMPILER_ENDPOINT', - '%s://%s:%s/%s' % (COMPILER_PROTO, COMPILER_HOST, COMPILER_PORT, - COMPILER_PATH) -) -""" -Full URL to the root compiler service API endpoint. - -If not explicitly provided, this is composed from :const:`COMPILER_HOST`, -:const:`COMPILER_PORT`, :const:`COMPILER_PROTO`, and :const:`COMPILER_PATH`. -""" - -COMPILER_STATUS_TIMEOUT \ - = float(environ.get('COMPILER_STATUS_TIMEOUT', 1.0)) - -COMPILER_VERIFY = bool(int(environ.get('COMPILER_VERIFY', '1'))) -"""Enable/disable SSL certificate verification for compiler service.""" - -if COMPILER_PROTO == 'https' and not COMPILER_VERIFY: - warnings.warn('Certificate verification for compiler is disabled; this' - ' should not be disabled in production.') - - -EXTERNAL_URL_SCHEME = environ.get('EXTERNAL_URL_SCHEME', 'https') -BASE_SERVER = environ.get('BASE_SERVER', 'arxiv.org') - -URLS = [ - ("help_license", "/help/license", BASE_SERVER), - ("help_third_party_submission", "/help/third_party_submission", - BASE_SERVER), - ("help_cross", "/help/cross", BASE_SERVER), - ("help_submit", "/help/submit", BASE_SERVER), - ("help_ancillary_files", "/help/ancillary_files", BASE_SERVER), - ("help_texlive", "/help/faq/texlive", BASE_SERVER), - ("help_whytex", "/help/faq/whytex", BASE_SERVER), - ("help_default_packages", "/help/submit_tex#wegotem", BASE_SERVER), - ("help_submit_tex", "/help/submit_tex", BASE_SERVER), - ("help_submit_pdf", "/help/submit_pdf", BASE_SERVER), - ("help_submit_ps", "/help/submit_ps", BASE_SERVER), - ("help_submit_html", "/help/submit_html", BASE_SERVER), - ("help_submit_sizes", "/help/sizes", BASE_SERVER), - ("help_metadata", "/help/prep", BASE_SERVER), - ("help_jref", "/help/jref", BASE_SERVER), - ("help_withdraw", "/help/withdraw", BASE_SERVER), - ("help_replace", "/help/replace", BASE_SERVER), - ("help_endorse", "/help/endorsement", BASE_SERVER), - ("clickthrough", "/ct?url=&v=", BASE_SERVER), - ("help_endorse", "/help/endorsement", BASE_SERVER), - ("help_replace", "/help/replace", BASE_SERVER), - ("help_version", "/help/replace#versions", BASE_SERVER), - ("help_email", "/help/email-protection", BASE_SERVER), - ("help_author", "/help/prep#author", BASE_SERVER), - ("help_mistakes", "/help/faq/mistakes", BASE_SERVER), - ("help_texprobs", "/help/faq/texprobs", BASE_SERVER), - ("login", "/user/login", BASE_SERVER) -] -""" -URLs for external services, for use with :func:`flask.url_for`. -This subset of URLs is common only within submit, for now - maybe move to base -if these pages seem relevant to other services. - -For details, see :mod:`arxiv.base.urls`. -""" - -AUTH_UPDATED_SESSION_REF = True -""" -Authn/z info is at ``request.auth`` instead of ``request.session``. - -See `https://arxiv-org.atlassian.net/browse/ARXIVNG-2186`_. -""" - -# --- AWS CONFIGURATION --- - -AWS_ACCESS_KEY_ID = environ.get('AWS_ACCESS_KEY_ID', 'nope') -""" -Access key for requests to AWS services. - -If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. -""" - -AWS_SECRET_ACCESS_KEY = environ.get('AWS_SECRET_ACCESS_KEY', 'nope') -""" -Secret auth key for requests to AWS services. - -If :const:`VAULT_ENABLED` is ``True``, this will be overwritten. -""" - -AWS_REGION = environ.get('AWS_REGION', 'us-east-1') -"""Default region for calling AWS services.""" - - -# --- KINESIS CONFIGURATION --- - -KINESIS_STREAM = environ.get("KINESIS_STREAM", "SubmissionEvents") -"""Name of the stream on which to produce and consume events.""" - -KINESIS_SHARD_ID = environ.get("KINESIS_SHARD_ID", "0") -""" -Shard ID for this agent instance. - -There must only be one agent process running per shard. -""" - -KINESIS_START_TYPE = environ.get("KINESIS_START_TYPE", "TRIM_HORIZON") -"""Start type to use when no checkpoint is available.""" - -KINESIS_ENDPOINT = environ.get("KINESIS_ENDPOINT", None) -""" -Alternate endpoint for connecting to Kinesis. - -If ``None``, uses the boto3 defaults for the :const:`AWS_REGION`. This is here -mainly to support development with localstack or other mocking frameworks. -""" - -KINESIS_VERIFY = bool(int(environ.get("KINESIS_VERIFY", "1"))) -""" -Enable/disable TLS certificate verification when connecting to Kinesis. - -This is here support development with localstack or other mocking frameworks. -""" - -if not KINESIS_VERIFY: - warnings.warn('Certificate verification for Kinesis is disabled; this' - ' should not be disabled in production.') - - -# --- VAULT INTEGRATION CONFIGURATION --- - -VAULT_ENABLED = bool(int(environ.get('VAULT_ENABLED', '0'))) -"""Enable/disable secret retrieval from Vault.""" - -KUBE_TOKEN = environ.get('KUBE_TOKEN', 'fookubetoken') -"""Service account token for authenticating with Vault. May be a file path.""" - -VAULT_HOST = environ.get('VAULT_HOST', 'foovaulthost') -"""Vault hostname/address.""" - -VAULT_PORT = environ.get('VAULT_PORT', '1234') -"""Vault API port.""" - -VAULT_ROLE = environ.get('VAULT_ROLE', 'ui-app-ui') -"""Vault role linked to this application's service account.""" - -VAULT_CERT = environ.get('VAULT_CERT') -"""Path to CA certificate for TLS verification when talking to Vault.""" - -VAULT_SCHEME = environ.get('VAULT_SCHEME', 'https') -"""Default is ``https``.""" - -NS_AFFIX = '' if NAMESPACE == 'production' else f'-{NAMESPACE}' -VAULT_REQUESTS = [ - {'type': 'generic', - 'name': 'JWT_SECRET', - 'mount_point': f'secret{NS_AFFIX}/', - 'path': 'jwt', - 'key': 'jwt-secret', - 'minimum_ttl': 3600}, - {'type': 'aws', - 'name': 'AWS_S3_CREDENTIAL', - 'mount_point': f'aws{NS_AFFIX}/', - 'role': environ.get('VAULT_CREDENTIAL')}, - {'type': 'generic', - 'name': 'SQLALCHEMY_DATABASE_URI', - 'mount_point': f'secret{NS_AFFIX}/', - 'path': 'beta-mysql', - 'key': 'uri', - 'minimum_ttl': 360000}, -] -"""Requests for Vault secrets.""" diff --git a/submit/controllers/__init__.py b/submit/controllers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/submit/controllers/api/__init__.py b/submit/controllers/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/submit/controllers/ui/__init__.py b/submit/controllers/ui/__init__.py deleted file mode 100644 index 09d7969..0000000 --- a/submit/controllers/ui/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Request controllers for the ui-app UI.""" - -from typing import Tuple, Dict, Any - -from arxiv_auth.domain import Session -from werkzeug.datastructures import MultiDict - -from http import HTTPStatus as status - -from . import util, jref, withdraw, delete, cross - -from .new.authorship import authorship -from .new.classification import classification, cross_list -from .new.create import create -from .new.final import finalize -from .new.license import license -from .new.metadata import metadata -from .new.metadata import optional -from .new.policy import policy -from .new.verify_user import verify -from .new.unsubmit import unsubmit - -from .new import process -from .new import upload - -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, advance_to_current - -from .util import Response - - -# def submission_status(method: str, params: MultiDict, session: Session, -# submission_id: int) -> Response: -# user, client = util.user_and_client_from_session(session) - -# # Will raise NotFound if there is no such ui-app. -# ui-app, submission_events = load_submission(submission_id) -# response_data = { -# 'ui-app': ui-app, -# 'submission_id': submission_id, -# 'events': submission_events -# } -# return response_data, status.OK, {} - - -def submission_edit(method: str, params: MultiDict, session: Session, - submission_id: int) -> Response: - """Cause flow_control to go to the current_stage of the Submission.""" - submission, submission_events = load_submission(submission_id) - response_data = { - 'ui-app': submission, - 'submission_id': submission_id, - 'events': submission_events, - } - return advance_to_current((response_data, status.OK, {})) diff --git a/submit/controllers/ui/cross.py b/submit/controllers/ui/cross.py deleted file mode 100644 index 1427042..0000000 --- a/submit/controllers/ui/cross.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Controller for cross-list requests.""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional, List - -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form, widgets -from wtforms.fields import Field, BooleanField, HiddenField -from wtforms.validators import InputRequired, ValidationError, optional, \ - DataRequired - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv.submission import save, Submission -from arxiv.submission.domain.event import RequestCrossList -from arxiv.submission.exceptions import SaveError -from arxiv.taxonomy import CATEGORIES_ACTIVE as CATEGORIES -from arxiv.taxonomy import ARCHIVES_ACTIVE as ARCHIVES -from arxiv_auth.domain import Session - -from ...util import load_submission -from .util import user_and_client_from_session, OptGroupSelectField, \ - validate_command - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -CONTACT_SUPPORT = Markup( - 'If you continue to experience problems, please contact' - ' arXiv support.' -) - - -class HiddenListField(HiddenField): - def process_formdata(self, valuelist): - self.data = list(str(x) for x in valuelist if x) - - def process_data(self, value): - try: - self.data = list(str(v) for v in value if v) - except (ValueError, TypeError): - self.data = None - - def _value(self): - return ",".join(self.data) if self.data else "" - - -class CrossListForm(csrf.CSRFForm): - """Submit a cross-list request.""" - - CATEGORIES = [ - (archive['name'], [ - (category_id, f"{category['name']} ({category_id})") - for category_id, category in CATEGORIES.items() - if category['in_archive'] == archive_id - ]) - for archive_id, archive in ARCHIVES.items() - ] - """Categories grouped by archive.""" - - ADD = 'add' - REMOVE = 'remove' - OPERATIONS = [ - (ADD, 'Add'), - (REMOVE, 'Remove') - ] - operation = HiddenField(default=ADD, validators=[optional()]) - category = OptGroupSelectField('Category', choices=CATEGORIES, - default='', validators=[optional()]) - selected = HiddenListField() - confirmed = BooleanField('Confirmed', - false_values=('false', False, 0, '0', '')) - - def validate_selected(form: csrf.CSRFForm, field: Field) -> None: - if form.confirmed.data and not field.data: - raise ValidationError('Please select a category') - for value in field.data: - if value not in CATEGORIES: - raise ValidationError('Not a valid category') - - def validate_category(form: csrf.CSRFForm, field: Field) -> None: - if not form.confirmed.data and not field.data: - raise ValidationError('Please select a category') - - def filter_choices(self, submission: Submission, session: Session, - exclude: Optional[List[str]] = None) -> None: - """Remove redundant choices, and limit to endorsed categories.""" - selected: List[str] = self.category.data - primary = submission.primary_classification - - choices = [ - (archive, [ - (category, display) for category, display in archive_choices - if (exclude is not None and category not in exclude - and (primary is None or category != primary.category) - and category not in submission.secondary_categories) - or category in selected - ]) - for archive, archive_choices in self.category.choices - ] - self.category.choices = [ - (archive, _choices) for archive, _choices in choices - if len(_choices) > 0 - ] - - @classmethod - def formset(cls, selected: List[str]) -> Dict[str, 'CrossListForm']: - """Generate a set of forms to add/remove categories in the template.""" - formset = {} - for category in selected: - if not category: - continue - subform = cls(operation=cls.REMOVE, category=category) - subform.category.widget = widgets.HiddenInput() - formset[category] = subform - return formset - - -def request_cross(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Request cross-list classification for an announced e-print.""" - submitter, client = user_and_client_from_session(session) - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - - # Will raise NotFound if there is no such ui-app. - submission, submission_events = load_submission(submission_id) - - # The ui-app must be announced for this to be a cross-list request. - if not submission.is_announced: - alerts.flash_failure( - Markup("Submission must first be announced. See the arXiv help" - " pages for details.")) - status_url = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': status_url} - - if method == 'GET': - params = MultiDict({}) - - params.setdefault("confirmed", False) - params.setdefault("operation", CrossListForm.ADD) - form = CrossListForm(params) - selected = [v for v in form.selected.data if v] - form.filter_choices(submission, session, exclude=selected) - - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'form': form, - 'selected': selected, - 'formset': CrossListForm.formset(selected) - } - if submission.primary_classification: - response_data['primary'] = \ - CATEGORIES[submission.primary_classification.category] - - if method == 'POST': - if not form.validate(): - raise BadRequest(response_data) - - if form.confirmed.data: # Stop adding new categories, and submit. - response_data['form'].operation.data = CrossListForm.ADD - response_data['require_confirmation'] = True - - command = RequestCrossList(creator=submitter, client=client, - categories=form.selected.data) - if not validate_command(form, command, submission, 'category'): - alerts.flash_failure(Markup( - "There was a problem with your request. Please try again." - f" {CONTACT_SUPPORT}" - )) - raise BadRequest(response_data) - - try: # Submit the cross-list request. - save(command, submission_id=submission_id) - except SaveError as e: - # This would be due to a database error, or something else - # that likely isn't the user's fault. - logger.error('Could not save cross list request event') - alerts.flash_failure(Markup( - "There was a problem processing your request. Please try" - f" again. {CONTACT_SUPPORT}" - )) - raise InternalServerError(response_data) from e - - # Success! Send user back to the ui-app page. - alerts.flash_success("Cross-list request submitted.") - status_url = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': status_url} - else: # User is adding or removing a category. - if form.operation.data: - if form.operation.data == CrossListForm.REMOVE: - selected.remove(form.category.data) - elif form.operation.data == CrossListForm.ADD: - selected.append(form.category.data) - # Update the "remove" formset to reflect the change. - response_data['formset'] = CrossListForm.formset(selected) - response_data['selected'] = selected - # Now that we've handled the request, get a fresh form for adding - # more categories or submitting the request. - response_data['form'] = CrossListForm() - response_data['form'].filter_choices(submission, session, - exclude=selected) - response_data['form'].operation.data = CrossListForm.ADD - response_data['require_confirmation'] = True - return response_data, status.OK, {} - return response_data, status.OK, {} diff --git a/submit/controllers/ui/delete.py b/submit/controllers/ui/delete.py deleted file mode 100644 index ac69213..0000000 --- a/submit/controllers/ui/delete.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Provides controllers used to delete/roll back a ui-app.""" - -from http import HTTPStatus as status -from typing import Optional - -from flask import url_for -from wtforms import BooleanField, validators -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest, InternalServerError, NotFound - -from arxiv.base import logging, alerts -from arxiv.submission import save -from arxiv.submission.domain.event import Rollback, CancelRequest -from arxiv.submission.domain import WithdrawalRequest, \ - CrossListClassificationRequest, UserRequest -from arxiv.forms import csrf -from arxiv_auth.domain import Session -from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command -from submit.util import load_submission - - -class DeleteForm(csrf.CSRFForm): - """Form for deleting a ui-app or a revision.""" - - confirmed = BooleanField('Confirmed', - validators=[validators.DataRequired()]) - - -class CancelRequestForm(csrf.CSRFForm): - """Form for cancelling a request.""" - - confirmed = BooleanField('Confirmed', - validators=[validators.DataRequired()]) - - -def delete(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """ - Delete a ui-app, replacement, or other request. - - We never really DELETE-delete anything. The workhorse is - :class:`.Rollback`. For new submissions, this just makes the ui-app - inactive (disappear from user views). For replacements, or other kinds of - requests that happen after the first version is announced, the ui-app - is simply reverted back to the state of the last announcement. - - """ - submission, submission_events = load_submission(submission_id) - response_data = { - 'ui-app': submission, - 'submission_id': submission.submission_id, - } - - if method == 'GET': - form = DeleteForm() - response_data.update({'form': form}) - return response_data, status.OK, {} - elif method == 'POST': - form = DeleteForm(params) - response_data.update({'form': form}) - if form.validate() and form.confirmed.data: - user, client = user_and_client_from_session(session) - command = Rollback(creator=user, client=client) - if not validate_command(form, command, submission, 'confirmed'): - raise BadRequest(response_data) - - try: - save(command, submission_id=submission_id) - except Exception as e: - alerts.flash_failure("Whoops!") - raise InternalServerError(response_data) from e - redirect = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': redirect} - response_data.update({'form': form}) - raise BadRequest(response_data) - - -def cancel_request(method: str, params: MultiDict, session: Session, - submission_id: int, request_id: str, - **kwargs) -> Response: - submission, submission_events = load_submission(submission_id) - - # if request_type == WithdrawalRequest.NAME.lower(): - # request_klass = WithdrawalRequest - # elif request_type == CrossListClassificationRequest.NAME.lower(): - # request_klass = CrossListClassificationRequest - if request_id in submission.user_requests: - user_request = submission.user_requests[request_id] - else: - raise NotFound('No such request') - - # # Get the most recent user request of this type. - # this_request: Optional[UserRequest] = None - # for user_request in ui-app.active_user_requests[::-1]: - # if isinstance(user_request, request_klass): - # this_request = user_request - # break - # if this_request is None: - # raise NotFound('No such request') - - if not user_request.is_pending(): - raise BadRequest(f'Request is already {user_request.status}') - - response_data = { - 'ui-app': submission, - 'submission_id': submission.submission_id, - 'request_id': user_request.request_id, - 'user_request': user_request, - } - - if method == 'GET': - form = CancelRequestForm() - response_data.update({'form': form}) - return response_data, status.OK, {} - elif method == 'POST': - form = CancelRequestForm(params) - response_data.update({'form': form}) - if form.validate() and form.confirmed.data: - user, client = user_and_client_from_session(session) - command = CancelRequest(request_id=request_id, creator=user, - client=client) - if not validate_command(form, command, submission, 'confirmed'): - raise BadRequest(response_data) - - try: - save(command, submission_id=submission_id) - except Exception as e: - alerts.flash_failure("Whoops!" + str(e)) - raise InternalServerError(response_data) from e - redirect = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': redirect} - response_data.update({'form': form}) - raise BadRequest(response_data) diff --git a/submit/controllers/ui/jref.py b/submit/controllers/ui/jref.py deleted file mode 100644 index 77419df..0000000 --- a/submit/controllers/ui/jref.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Controller for JREF submissions.""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, List - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv_auth.domain import Session -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms.fields import StringField, BooleanField -from wtforms.validators import optional - -from arxiv.submission import save, Event, User, Client, Submission -from arxiv.submission.domain.event import SetDOI, SetJournalReference, \ - SetReportNumber -from arxiv.submission.exceptions import SaveError -from .util import user_and_client_from_session, FieldMixin, validate_command -from ...util import load_submission - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -class JREFForm(csrf.CSRFForm, FieldMixin): - """Set DOI and/or journal reference on a announced ui-app.""" - - doi = StringField('DOI', validators=[optional()], - description=("Full DOI of the version of record. For" - " example:" - " 10.1016/S0550-3213(01)00405-9" - )) - journal_ref = StringField('Journal reference', validators=[optional()], - description=( - "For example: Nucl.Phys.Proc.Suppl. 109" - " (2002) 3-9. See" - " " - "the arXiv help pages for details.")) - report_num = StringField('Report number', validators=[optional()], - description=( - "For example: SU-4240-720." - " Multiple report numbers should be separated" - " with a semi-colon and a space, for example:" - " SU-4240-720; LAUR-01-2140." - " See " - "the arXiv help pages for details.")) - confirmed = BooleanField('Confirmed', - false_values=('false', False, 0, '0', '')) - - -def jref(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Set journal reference metadata on a announced ui-app.""" - creator, client = user_and_client_from_session(session) - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - - # Will raise NotFound if there is no such ui-app. - submission, submission_events = load_submission(submission_id) - - # The ui-app must be announced for this to be a real JREF ui-app. - if not submission.is_announced: - alerts.flash_failure(Markup("Submission must first be announced. See " - "" - "the arXiv help pages for details.")) - status_url = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': status_url} - - # The form should be prepopulated based on the current state of the - # ui-app. - if method == 'GET': - params = MultiDict({ - 'doi': submission.metadata.doi, - 'journal_ref': submission.metadata.journal_ref, - 'report_num': submission.metadata.report_num - }) - - params.setdefault("confirmed", False) - form = JREFForm(params) - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'form': form, - } - - if method == 'POST': - # We require the user to confirm that they wish to proceed. We show - # them a preview of what their paper's abs page will look like after - # the proposed change. They can either make further changes, or - # confirm and submit. - if not form.validate(): - logger.debug('Invalid form data; return bad request') - raise BadRequest(response_data) - - if not form.confirmed.data: - response_data['require_confirmation'] = True - logger.debug('Not confirmed') - return response_data, status.OK, {} - - commands, valid = _generate_commands(form, submission, creator, client) - - if commands: # Metadata has changed; we have things to do. - if not all(valid): - raise BadRequest(response_data) - - response_data['require_confirmation'] = True - logger.debug('Form is valid, with data: %s', str(form.data)) - try: - # Save the events created during form validation. - submission, _ = save(*commands, submission_id=submission_id) - except SaveError as e: - logger.error('Could not save metadata event') - raise InternalServerError(response_data) from e - response_data['ui-app'] = submission - - # Success! Send user back to the ui-app page. - alerts.flash_success("Journal reference updated") - status_url = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': status_url} - logger.debug('Nothing to do, return 200') - return response_data, status.OK, {} - - -def _generate_commands(form: JREFForm, submission: Submission, creator: User, - client: Client) -> Tuple[List[Event], List[bool]]: - commands: List[Event] = [] - valid: List[bool] = [] - - if form.report_num.data and submission.metadata \ - and form.report_num.data != submission.metadata.report_num: - command = SetReportNumber(report_num=form.report_num.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'report_num')) - commands.append(command) - - if form.journal_ref.data and submission.metadata \ - and form.journal_ref.data != submission.metadata.journal_ref: - command = SetJournalReference(journal_ref=form.journal_ref.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, - 'journal_ref')) - commands.append(command) - - if form.doi.data and submission.metadata \ - and form.doi.data != submission.metadata.doi: - command = SetDOI(doi=form.doi.data, creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'doi')) - commands.append(command) - return commands, valid diff --git a/submit/controllers/ui/new/__init__.py b/submit/controllers/ui/new/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/submit/controllers/ui/new/authorship.py b/submit/controllers/ui/new/authorship.py deleted file mode 100644 index e524b22..0000000 --- a/submit/controllers/ui/new/authorship.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Controller for authorship action. - -Creates an event of type `core.events.event.ConfirmAuthorship` -""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional - -from flask import url_for -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import BooleanField, RadioField -from wtforms.validators import InputRequired, ValidationError, optional - -from arxiv.base import logging -from arxiv.forms import csrf -from arxiv_auth.domain import Session -from arxiv.submission import save -from arxiv.submission.domain import Submission -from arxiv.submission.domain.event import ConfirmAuthorship -from arxiv.submission.exceptions import InvalidEvent, SaveError - -from submit.util import load_submission -from submit.controllers.ui.util import user_and_client_from_session, validate_command - -# from arxiv-ui-app-core.events.event import ConfirmContactInformation -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -def authorship(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Handle the authorship assertion view.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - - # The form should be prepopulated based on the current state of the - # ui-app. - if method == 'GET': - # Update form data based on the current state of the ui-app. - if submission.submitter_is_author is not None: - if submission.submitter_is_author: - params['authorship'] = AuthorshipForm.YES - else: - params['authorship'] = AuthorshipForm.NO - if submission.submitter_is_author is False: - params['proxy'] = True - - form = AuthorshipForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission, - 'submitter': submitter, - 'client': client, - } - - if method == 'POST' and form.validate(): - value = (form.authorship.data == form.YES) - # No need to do this more than once. - if submission.submitter_is_author != value: - command = ConfirmAuthorship(creator=submitter, client=client, - submitter_is_author=value) - if validate_command(form, command, submission, 'authorship'): - try: - submission, _ = save(command, submission_id=submission_id) - response_data['ui-app'] = submission - return response_data, status.SEE_OTHER, {} - except SaveError as e: - raise InternalServerError(response_data) from e - return ready_for_next((response_data, status.OK, {})) - - return response_data, status.OK, {} - - -class AuthorshipForm(csrf.CSRFForm): - """Generate form with radio button to confirm authorship information.""" - - YES = 'y' - NO = 'n' - - authorship = RadioField(choices=[(YES, 'I am an author of this paper'), - (NO, 'I am not an author of this paper')], - validators=[InputRequired('Please choose one')]) - proxy = BooleanField('By checking this box, I certify that I have ' - 'received authorization from arXiv to submit papers ' - 'on behalf of the author(s).', - validators=[optional()]) - - def validate_authorship(self, field: RadioField) -> None: - """Require proxy field if submitter is not author.""" - if field.data == self.NO and not self.data.get('proxy'): - raise ValidationError('You must get prior approval to submit ' - 'on behalf of authors') diff --git a/submit/controllers/ui/new/classification.py b/submit/controllers/ui/new/classification.py deleted file mode 100644 index be4a357..0000000 --- a/submit/controllers/ui/new/classification.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Controller for classification actions. - -Creates an event of type `core.events.event.SetPrimaryClassification` -Creates an event of type `core.events.event.AddSecondaryClassification` -""" -from typing import Tuple, Dict, Any, List, Optional -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from flask import url_for, Markup -from wtforms import SelectField, widgets, HiddenField, validators - -from http import HTTPStatus as status -from arxiv import taxonomy -from arxiv.forms import csrf -from arxiv.base import logging, alerts -from arxiv.submission.domain import Submission -from arxiv.submission import save -from arxiv.submission.exceptions import InvalidEvent, SaveError -from arxiv_auth.domain import Session -from arxiv.submission.domain.event import RemoveSecondaryClassification, \ - AddSecondaryClassification, SetPrimaryClassification - -from submit.controllers.ui.util import validate_command, OptGroupSelectField, \ - user_and_client_from_session -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -class ClassificationForm(csrf.CSRFForm): - """Form for classification selection.""" - - CATEGORIES = [ - (archive['name'], [ - (category_id, f"{category['name']} ({category_id})") - for category_id, category in taxonomy.CATEGORIES_ACTIVE.items() - if category['in_archive'] == archive_id - ]) - for archive_id, archive in taxonomy.ARCHIVES_ACTIVE.items() - ] - """Categories grouped by archive.""" - - ADD = 'add' - REMOVE = 'remove' - OPERATIONS = [ - (ADD, 'Add'), - (REMOVE, 'Remove') - ] - operation = HiddenField(default=ADD, validators=[validators.optional()]) - category = OptGroupSelectField('Category', choices=CATEGORIES, default='') - - def filter_choices(self, submission: Submission, session: Session) -> None: - """Remove redundant choices, and limit to endorsed categories.""" - selected = self.category.data - primary = submission.primary_classification - - choices = [ - (archive, [ - (category, display) for category, display in archive_choices - if session.authorizations.endorsed_for(category) - and (((primary is None or category != primary.category) - and category not in submission.secondary_categories) - or category == selected) - ]) - for archive, archive_choices in self.category.choices - ] - self.category.choices = [ - (archive, _choices) for archive, _choices in choices - if len(_choices) > 0 - ] - - @classmethod - def formset(cls, submission: Submission) \ - -> Dict[str, 'ClassificationForm']: - """Generate a set of forms used to remove cross-list categories.""" - formset = {} - if hasattr(submission, 'secondary_classification') and \ - submission.secondary_classification: - for ix, secondary in enumerate(submission.secondary_classification): - this_category = str(secondary.category) - subform = cls(operation=cls.REMOVE, category=this_category) - subform.category.widget = widgets.HiddenInput() - subform.category.id = f"{ix}_category" - subform.operation.id = f"{ix}_operation" - subform.csrf_token.id = f"{ix}_csrf_token" - formset[secondary.category] = subform - return formset - - -class PrimaryClassificationForm(ClassificationForm): - """Form for setting the primary classification.""" - - def validate_operation(self, field) -> None: - """Make sure the client isn't monkeying with the operation.""" - if field.data != self.ADD: - raise validators.ValidationError('Invalid operation') - - -def classification(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Handle primary classification requests for a new ui-app.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - - if method == 'GET': - # Prepopulate the form based on the state of the ui-app. - if submission.primary_classification \ - and submission.primary_classification.category: - params['category'] = submission.primary_classification.category - - # Use the user's default category as the default for the form. - params.setdefault('category', session.user.profile.default_category) - - params['operation'] = PrimaryClassificationForm.ADD - - form = PrimaryClassificationForm(params) - form.filter_choices(submission, session) - - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'submitter': submitter, - 'client': client, - 'form': form - } - - command = SetPrimaryClassification(category=form.category.data, - creator=submitter, client=client) - if method == 'POST' and form.validate()\ - and validate_command(form, command, submission, 'category'): - try: - submission, _ = save(command, submission_id=submission_id) - response_data['ui-app'] = submission - except SaveError as ex: - raise InternalServerError(response_data) from ex - return ready_for_next((response_data, status.OK, {})) - - return response_data, status.OK, {} - - -def cross_list(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Handle secondary classification requests for a new submision.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - - form = ClassificationForm(params) - form.operation._value = lambda: form.operation.data - form.filter_choices(submission, session) - - # Create a formset to render removal option. - # - # We need forms for existing secondaries, to generate removal requests. - # When the forms in the formset are submitted, they are handled as the - # primary form in the POST request to this controller. - formset = ClassificationForm.formset(submission) - _primary_category = submission.primary_classification.category - _primary = taxonomy.CATEGORIES[_primary_category] - - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'submitter': submitter, - 'client': client, - 'form': form, - 'formset': formset, - 'primary': { - 'id': submission.primary_classification.category, - 'name': _primary['name'] - }, - } - - # Ensure the user is not attempting to move to a different step. - # Since the interface provides an "add" button to add cross-list - # categories, we only want to handle the form data if the user is not - # attempting to move to a different step. - - if form.operation.data == form.REMOVE: - command_type = RemoveSecondaryClassification - else: - command_type = AddSecondaryClassification - command = command_type(category=form.category.data, - creator=submitter, client=client) - if method == 'POST' and form.validate() \ - and validate_command(form, command, submission, 'category'): - try: - submission, _ = save(command, submission_id=submission_id) - response_data['ui-app'] = submission - - # Re-build the formset to reflect changes that we just made, and - # generate a fresh form for adding another secondary. The POSTed - # data should now be reflected in the formset. - response_data['formset'] = ClassificationForm.formset(submission) - form = ClassificationForm() - form.operation._value = lambda: form.operation.data - form.filter_choices(submission, session) - response_data['form'] = form - - # do not go to next yet, re-show cross form - return stay_on_this_stage((response_data, status.OK, {})) - except SaveError as ex: - raise InternalServerError(response_data) from ex - - - if len(submission.secondary_categories) > 3: - alerts.flash_warning(Markup( - 'Adding more than three cross-list classifications will' - ' result in a delay in the acceptance of your ui-app.' - )) - return response_data, status.OK, {} diff --git a/submit/controllers/ui/new/create.py b/submit/controllers/ui/new/create.py deleted file mode 100644 index d38cac3..0000000 --- a/submit/controllers/ui/new/create.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Controller for creating a new ui-app.""" - -from http import HTTPStatus as status -from typing import Optional, Tuple, Dict, Any - -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest, \ - MethodNotAllowed -from flask import url_for -from retry import retry - -from arxiv.forms import csrf -from arxiv.base import logging -from arxiv_auth.domain import Session, User - -from arxiv.submission import save -from arxiv.submission.domain import Submission -from arxiv.submission.domain.event import CreateSubmission, \ - CreateSubmissionVersion -from arxiv.submission.exceptions import InvalidEvent, SaveError -from arxiv.submission.core import load_submissions_for_user - -from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage, advance_to_current - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - - -class CreateSubmissionForm(csrf.CSRFForm): - """Submission creation form.""" - - -def create(method: str, params: MultiDict, session: Session, *args, - **kwargs) -> Response: - """Create a new ui-app, and redirect to workflow.""" - submitter, client = user_and_client_from_session(session) - response_data = {} - if method == 'GET': # Display a splash page. - response_data['user_submissions'] \ - = _load_submissions_for_user(session.user.user_id) - params = MultiDict() - - # We're using a form here for CSRF protection. - form = CreateSubmissionForm(params) - response_data['form'] = form - - command = CreateSubmission(creator=submitter, client=client) - if method == 'POST' and form.validate() and validate_command(form, command): - try: - submission, _ = save(command) - except SaveError as e: - logger.error('Could not save command: %s', e) - raise InternalServerError(response_data) from e - - # TODO Do we need a better way to enter a workflow? - # Maybe a controller that is defined as the entrypoint? - loc = url_for('ui.verify_user', submission_id=submission.submission_id) - return {}, status.SEE_OTHER, {'Location': loc} - - return advance_to_current((response_data, status.OK, {})) - - -def replace(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Create a new version, and redirect to workflow.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'submitter': submitter, - 'client': client, - } - - if method == 'GET': # Display a splash page. - response_data['form'] = CreateSubmissionForm() - - if method == 'POST': - # We're using a form here for CSRF protection. - form = CreateSubmissionForm(params) - response_data['form'] = form - if not form.validate(): - raise BadRequest('Invalid request') - - submitter, client = user_and_client_from_session(session) - submission, _ = load_submission(submission_id) - command = CreateSubmissionVersion(creator=submitter, client=client) - if not validate_command(form, command, submission): - raise BadRequest({}) - - try: - submission, _ = save(command, submission_id=submission_id) - except SaveError as e: - logger.error('Could not save command: %s', e) - raise InternalServerError({}) from e - - loc = url_for('ui.verify_user', submission_id=submission.submission_id) - return {}, status.SEE_OTHER, {'Location': loc} - return response_data, status.OK, {} - - -@retry(tries=3, delay=0.1, backoff=2) -def _load_submissions_for_user(user_id: str): - return load_submissions_for_user(user_id) diff --git a/submit/controllers/ui/new/final.py b/submit/controllers/ui/new/final.py deleted file mode 100644 index 92f8b39..0000000 --- a/submit/controllers/ui/new/final.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Provides the final preview and confirmation step. -""" - -from typing import Tuple, Dict, Any - -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from flask import url_for -from wtforms import BooleanField -from wtforms.validators import InputRequired - -from http import HTTPStatus as status -from arxiv.forms import csrf -from arxiv.base import logging -from arxiv_auth.domain import Session -from arxiv.submission import save -from arxiv.submission.domain.event import FinalizeSubmission -from arxiv.submission.exceptions import SaveError -from submit.util import load_submission -from submit.controllers.ui.util import validate_command, user_and_client_from_session -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -def finalize(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - submitter, client = user_and_client_from_session(session) - - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - submission, submission_events = load_submission(submission_id) - - form = FinalizationForm(params) - - # The abs preview macro expects a specific struct for ui-app history. - submission_history = [{'submitted_date': s.created, 'version': s.version} - for s in submission.versions] - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission, - 'submission_history': submission_history - } - - command = FinalizeSubmission(creator=submitter) - proofread_confirmed = form.proceed.data - if method == 'POST' and form.validate() \ - and proofread_confirmed \ - and validate_command(form, command, submission): - try: - submission, stack = save( # pylint: disable=W0612 - command, submission_id=submission_id) - except SaveError as e: - logger.error('Could not save primary event') - raise InternalServerError(response_data) from e - return ready_for_next((response_data, status.OK, {})) - else: - return stay_on_this_stage((response_data, status.OK, {})) - - return response_data, status.OK, {} - - -class FinalizationForm(csrf.CSRFForm): - """Make sure the user is really really really ready to submit.""" - - proceed = BooleanField( - 'By checking this box, I confirm that I have reviewed my ui-app as' - ' it will appear on arXiv.', - [InputRequired('Please confirm that the ui-app is ready')] - ) - - -def confirm(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - submission, submission_events = load_submission(submission_id) - response_data = { - 'submission_id': submission_id, - 'ui-app': submission - } - return response_data, status.OK, {} diff --git a/submit/controllers/ui/new/license.py b/submit/controllers/ui/new/license.py deleted file mode 100644 index cafa92e..0000000 --- a/submit/controllers/ui/new/license.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Controller for license action. - -Creates an event of type `core.events.event.SetLicense` -""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any - -from flask import url_for -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms.fields import RadioField -from wtforms.validators import InputRequired - -from arxiv.forms import csrf -from arxiv.base import logging -from arxiv.license import LICENSES -from arxiv_auth.domain import Session -from arxiv.submission import save, InvalidEvent, SaveError -from arxiv.submission.domain.event import SetLicense -from submit.util import load_submission -from submit.controllers.ui.util import validate_command, user_and_client_from_session -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -def license(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Convert license form data into a `SetLicense` event.""" - submitter, client = user_and_client_from_session(session) - - submission, submission_events = load_submission(submission_id) - - if method == 'GET' and submission.license: - # The form should be prepopulated based on the current state of the - # ui-app. - params['license'] = submission.license.uri - - form = LicenseForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission - } - - if method == 'POST' and form.validate(): - license_uri = form.license.data - if submission.license and submission.license.uri == license_uri: - return ready_for_next((response_data, status.OK, {})) - if not submission.license \ - or submission.license.uri != license_uri: - command = SetLicense(creator=submitter, client=client, - license_uri=license_uri) - if validate_command(form, command, submission, 'license'): - try: - submission, _ = save(command, submission_id=submission_id) - return ready_for_next((response_data, status.OK, {})) - except SaveError as e: - raise InternalServerError(response_data) from e - - return stay_on_this_stage((response_data, status.OK, {})) - - -class LicenseForm(csrf.CSRFForm): - """Generate form to select license.""" - - LICENSE_CHOICES = [(uri, data['label']) for uri, data in LICENSES.items() - if data['is_current']] - - license = RadioField(u'Select a license', choices=LICENSE_CHOICES, - validators=[InputRequired('Please select a license')]) diff --git a/submit/controllers/ui/new/metadata.py b/submit/controllers/ui/new/metadata.py deleted file mode 100644 index 8616d75..0000000 --- a/submit/controllers/ui/new/metadata.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Provides a controller for updating metadata on a ui-app.""" - -from typing import Tuple, Dict, Any, List - -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms.fields import StringField, TextAreaField, Field -from wtforms import validators - -from http import HTTPStatus as status -from arxiv.forms import csrf -from arxiv.base import logging -from arxiv_auth.domain import Session -from arxiv.submission import save, SaveError, Submission, User, Client, Event -from arxiv.submission.domain.event import SetTitle, SetAuthors, SetAbstract, \ - SetACMClassification, SetMSCClassification, SetComments, SetReportNumber, \ - SetJournalReference, SetDOI - -from submit.util import load_submission -from submit.controllers.ui.util import validate_command, FieldMixin, user_and_client_from_session -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -class CoreMetadataForm(csrf.CSRFForm, FieldMixin): - """Handles core metadata fields on a ui-app.""" - - title = StringField('*Title', validators=[validators.DataRequired()]) - authors_display = TextAreaField( - '*Authors', - validators=[validators.DataRequired()], - description=( - "use GivenName(s) FamilyName(s) or I. " - "FamilyName; separate individual authors with " - "a comma or 'and'." - ) - ) - abstract = TextAreaField('*Abstract', - validators=[validators.DataRequired()], - description='Limit of 1920 characters') - comments = StringField('Comments', - default='', - validators=[validators.optional()], - description=( - "Supplemental information such as number of pages " - "or figures, conference information." - )) - - -class OptionalMetadataForm(csrf.CSRFForm, FieldMixin): - """Handles optional metadata fields on a ui-app.""" - - doi = StringField('DOI', - validators=[validators.optional()], - description="Full DOI of the version of record.") - journal_ref = StringField('Journal reference', - validators=[validators.optional()], - description=( - "See " - "the arXiv help pages for details." - )) - report_num = StringField('Report number', - validators=[validators.optional()], - description=( - "See " - "the arXiv help pages for details." - )) - acm_class = StringField('ACM classification', - validators=[validators.optional()], - description="example: F.2.2; I.2.7") - - msc_class = StringField('MSC classification', - validators=[validators.optional()], - description=("example: 14J60 (Primary), 14F05, " - "14J26 (Secondary)")) - - -def _data_from_submission(params: MultiDict, submission: Submission, - form_class: type) -> MultiDict: - if not submission.metadata: - return params - for field in form_class.fields(): - params[field] = getattr(submission.metadata, field, '') - return params - - -def metadata(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Update metadata on the ui-app.""" - submitter, client = user_and_client_from_session(session) - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - - # Will raise NotFound if there is no such ui-app. - submission, submission_events = load_submission(submission_id) - # The form should be prepopulated based on the current state of the - # ui-app. - if method == 'GET': - params = _data_from_submission(params, submission, CoreMetadataForm) - - form = CoreMetadataForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission - } - - if method == 'POST' and form.validate(): - commands, valid = _commands(form, submission, submitter, client) - # We only want to apply an UpdateMetadata if the metadata has - # actually changed. - if commands and all(valid): # Metadata has changed and is valid - try: - # Save the events created during form validation. - submission, _ = save(*commands, submission_id=submission_id) - response_data['ui-app'] = submission - return ready_for_next((response_data, status.OK, {})) - except SaveError as e: - raise InternalServerError(response_data) from e - else: - return ready_for_next((response_data, status.OK, {})) - else: - return stay_on_this_stage((response_data, status.OK, {})) - - return response_data, status.OK, {} - - -def optional(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Update optional metadata on the ui-app.""" - submitter, client = user_and_client_from_session(session) - - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - - # Will raise NotFound if there is no such ui-app. - submission, submission_events = load_submission(submission_id) - # The form should be prepopulated based on the current state of the - # ui-app. - if method == 'GET': - params = _data_from_submission(params, submission, - OptionalMetadataForm) - - form = OptionalMetadataForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission - } - - if method == 'POST' and form.validate(): - logger.debug('Form is valid, with data: %s', str(form.data)) - - commands, valid = _opt_commands(form, submission, submitter, client) - # We only want to apply updates if the metadata has actually changed. - if not commands: - return ready_for_next((response_data, status.OK, {})) - if all(valid): # Metadata has changed and is all valid - try: - submission, _ = save(*commands, submission_id=submission_id) - response_data['ui-app'] = submission - return ready_for_next((response_data, status.OK, {})) - except SaveError as e: - raise InternalServerError(response_data) from e - - return stay_on_this_stage((response_data, status.OK, {})) - - -def _commands(form: CoreMetadataForm, submission: Submission, - creator: User, client: Client) -> Tuple[List[Event], List[bool]]: - commands: List[Event] = [] - valid: List[bool] = [] - - if form.title.data and submission.metadata \ - and form.title.data != submission.metadata.title: - command = SetTitle(title=form.title.data, creator=creator, - client=client) - valid.append(validate_command(form, command, submission, 'title')) - commands.append(command) - - if form.abstract.data and submission.metadata \ - and form.abstract.data != submission.metadata.abstract: - command = SetAbstract(abstract=form.abstract.data, creator=creator, - client=client) - valid.append(validate_command(form, command, submission, 'abstract')) - commands.append(command) - - if form.comments.data and submission.metadata \ - and form.comments.data != submission.metadata.comments: - command = SetComments(comments=form.comments.data, creator=creator, - client=client) - valid.append(validate_command(form, command, submission, 'comments')) - commands.append(command) - - value = form.authors_display.data - if value and submission.metadata \ - and value != submission.metadata.authors_display: - command = SetAuthors(authors_display=form.authors_display.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, - 'authors_display')) - commands.append(command) - return commands, valid - - -def _opt_commands(form: OptionalMetadataForm, submission: Submission, - creator: User, client: Client) \ - -> Tuple[List[Event], List[bool]]: - - commands: List[Event] = [] - valid: List[bool] = [] - - if form.msc_class.data and submission.metadata \ - and form.msc_class.data != submission.metadata.msc_class: - command = SetMSCClassification(msc_class=form.msc_class.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'msc_class')) - commands.append(command) - - if form.acm_class.data and submission.metadata \ - and form.acm_class.data != submission.metadata.acm_class: - command = SetACMClassification(acm_class=form.acm_class.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'acm_class')) - commands.append(command) - - if form.report_num.data and submission.metadata \ - and form.report_num.data != submission.metadata.report_num: - command = SetReportNumber(report_num=form.report_num.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'report_num')) - commands.append(command) - - if form.journal_ref.data and submission.metadata \ - and form.journal_ref.data != submission.metadata.journal_ref: - command = SetJournalReference(journal_ref=form.journal_ref.data, - creator=creator, client=client) - valid.append(validate_command(form, command, submission, - 'journal_ref')) - commands.append(command) - - if form.doi.data and submission.metadata \ - and form.doi.data != submission.metadata.doi: - command = SetDOI(doi=form.doi.data, creator=creator, client=client) - valid.append(validate_command(form, command, submission, 'doi')) - commands.append(command) - return commands, valid diff --git a/submit/controllers/ui/new/policy.py b/submit/controllers/ui/new/policy.py deleted file mode 100644 index b51e746..0000000 --- a/submit/controllers/ui/new/policy.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Controller for policy action. - -Creates an event of type `core.events.event.ConfirmPolicy` -""" -from http import HTTPStatus as status -from typing import Tuple, Dict, Any - -from flask import url_for -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms import BooleanField -from wtforms.validators import InputRequired - -from arxiv.forms import csrf -from arxiv.base import logging -from arxiv_auth.domain import Session -from arxiv.submission import save, SaveError -from arxiv.submission.domain.event import ConfirmPolicy - -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage -from submit.controllers.ui.util import validate_command, \ - user_and_client_from_session - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -def policy(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Convert policy form data into an `ConfirmPolicy` event.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - - if method == 'GET' and submission.submitter_accepts_policy: - params['policy'] = 'true' - - form = PolicyForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission - } - - if method == 'POST' and form.validate(): - accept_policy = form.policy.data - if accept_policy and submission.submitter_accepts_policy: - return ready_for_next((response_data, status.OK, {})) - if accept_policy and not submission.submitter_accepts_policy: - command = ConfirmPolicy(creator=submitter, client=client) - if validate_command(form, command, submission, 'policy'): - try: - submission, _ = save(command, submission_id=submission_id) - response_data['ui-app'] = submission - return ready_for_next((response_data, status.OK, {})) - except SaveError as e: - raise InternalServerError(response_data) from e - - return stay_on_this_stage((response_data, status.OK, {})) - - -class PolicyForm(csrf.CSRFForm): - """Generate form with checkbox to confirm policy.""" - - policy = BooleanField( - 'By checking this box, I agree to the policies listed on this page.', - [InputRequired('Please check the box to agree to the policies')] - ) diff --git a/submit/controllers/ui/new/process.py b/submit/controllers/ui/new/process.py deleted file mode 100644 index 0344dbd..0000000 --- a/submit/controllers/ui/new/process.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Controllers for process-related requests. - -The controllers in this module leverage -:mod:`arxiv.ui-app.core.process.process_source`, which provides an -high-level API for orchestrating source processing for all supported source -types. -""" - -import io -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv.integration.api import exceptions -from arxiv.submission import save, SaveError -from arxiv.submission.domain.event import ConfirmSourceProcessed -from arxiv.submission.process import process_source -from arxiv.submission.services import PreviewService, Compiler -from arxiv_auth.domain import Session -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, MethodNotAllowed -from wtforms import SelectField -from .reasons import TEX_PRODUCED_MARKUP, DOCKER_ERROR_MARKUOP, SUCCESS_MARKUP -from submit.controllers.ui.util import user_and_client_from_session -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage -from submit.util import load_submission - -logger = logging.getLogger(__name__) - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -SUPPORT = Markup( - 'If you continue to experience problems, please contact' - ' arXiv support.' -) - - -def file_process(method: str, params: MultiDict, session: Session, - submission_id: int, token: str, **kwargs: Any) -> Response: - """ - Process the file compilation project. - - Parameters - ---------- - method : str - ``GET`` or ``POST`` - session : :class:`Session` - The authenticated session for the request. - submission_id : int - The identifier of the ui-app for which the upload is being made. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``200`` or ``303``, unless something - goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response, if - applicable. - - """ - if method == "GET": - return compile_status(params, session, submission_id, token) - elif method == "POST": - if params.get('action') in ['previous', 'next', 'save_exit']: - return _check_status(params, session, submission_id, token) - # User is not actually trying to process anything; let flow control - # in the routes handle the response. - # TODO there is a chance this will allow the user to go to next stage without processing - # return ready_for_next({}, status.SEE_OTHER, {}) - else: - return start_compilation(params, session, submission_id, token) - raise MethodNotAllowed('Unsupported request') - - -def _check_status(params: MultiDict, session: Session, submission_id: int, - token: str, **kwargs: Any) -> None: - """ - Check for cases in which the preview already exists. - - This will catch cases in which the ui-app is PDF-only, or otherwise - requires no further compilation. - """ - submitter, client = user_and_client_from_session(session) - submission, _ = load_submission(submission_id) - - if not submission.is_source_processed: - form = CompilationForm(params) # Providing CSRF protection. - if not form.validate(): - return stay_on_this_stage(({'form': form}, status.OK, {})) - - command = ConfirmSourceProcessed(creator=submitter, client=client) - try: - submission, _ = save(command, submission_id=submission_id) - return ready_for_next(({}, status.OK, {})) - except SaveError as e: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please' - f' try again. {SUPPORT}' - )) - logger.error('Error while saving command %s: %s', - command.event_id, e) - raise InternalServerError('Could not save changes') from e - else: - return ready_for_next(({}, status.OK, {})) - - -def compile_status(params: MultiDict, session: Session, submission_id: int, - token: str, **kwargs: Any) -> Response: - """ - Returns the status of a compilation. - - Parameters - ---------- - session : :class:`Session` - The authenticated session for the request. - submission_id : int - The identifier of the ui-app for which the upload is being made. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``200`` or ``303``, unless something - goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response, if - applicable. - - """ - submitter, client = user_and_client_from_session(session) - submission, _ = load_submission(submission_id) - form = CompilationForm() - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'form': form, - 'status': None, - } - # Determine whether the current state of the uploaded source content has - # been compiled. - result: Optional[process_source.CheckResult] = None - try: - result = process_source.check(submission, submitter, client, token) - except process_source.NoProcessToCheck as e: - pass - except process_source.FailedToCheckStatus as e: - logger.error('Failed to check status: %s', e) - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {SUPPORT}' - )) - if result is not None: - response_data['status'] = result.status - response_data.update(**result.extra) - return stay_on_this_stage((response_data, status.OK, {})) - - -def start_compilation(params: MultiDict, session: Session, submission_id: int, - token: str, **kwargs: Any) -> Response: - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - form = CompilationForm(params) - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'form': form, - 'status': None, - } - - if not form.validate(): - return stay_on_this_stage((response_data,status.OK,{})) - - try: - result = process_source.start(submission, submitter, client, token) - except process_source.FailedToStart as e: - alerts.flash_failure(f"We couldn't process your ui-app. {SUPPORT}", - title="Processing failed") - logger.error('Error while requesting compilation for %s: %s', - submission_id, e) - raise InternalServerError(response_data) from e - - response_data['status'] = result.status - response_data.update(**result.extra) - - if result.status == process_source.FAILED: - if 'reason' in result.extra and "produced from TeX source" in result.extra['reason']: - alerts.flash_failure(TEX_PRODUCED_MARKUP) - elif 'reason' in result.extra and 'docker' in result.extra['reason']: - alerts.flash_failure(DOCKER_ERROR_MARKUOP) - else: - alerts.flash_failure(f"Processing failed") - else: - alerts.flash_success(SUCCESS_MARKUP, title="Processing started" - ) - - return stay_on_this_stage((response_data, status.OK, {})) - - -def file_preview(params, session: Session, submission_id: int, token: str, - **kwargs: Any) -> Tuple[io.BytesIO, int, Dict[str, str]]: - """Serve the PDF preview for a ui-app.""" - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - p = PreviewService.current_session() - stream, pdf_checksum = p.get(submission.source_content.identifier, - submission.source_content.checksum, - token) - headers = {'Content-Type': 'application/pdf', 'ETag': pdf_checksum} - return stream, status.OK, headers - - -def compilation_log(params, session: Session, submission_id: int, token: str, - **kwargs: Any) -> Response: - submitter, client = user_and_client_from_session(session) - submission, submission_events = load_submission(submission_id) - checksum = params.get('checksum', submission.source_content.checksum) - try: - log = Compiler.get_log(submission.source_content.identifier, checksum, - token) - headers = {'Content-Type': log.content_type, 'ETag': checksum} - return log.stream, status.OK, headers - except exceptions.NotFound: - raise NotFound("No log output produced") - - -def compile(params: MultiDict, session: Session, submission_id: int, - token: str, **kwargs) -> Response: - redirect = url_for('ui.file_process', submission_id=submission_id) - return {}, status.SEE_OTHER, {'Location': redirect} - - -class CompilationForm(csrf.CSRFForm): - """Generate form to process compilation.""" - - PDFLATEX = 'pdflatex' - COMPILERS = [ - (PDFLATEX, 'PDFLaTeX') - ] - - compiler = SelectField('Compiler', choices=COMPILERS, - default=PDFLATEX) diff --git a/submit/controllers/ui/new/reasons.py b/submit/controllers/ui/new/reasons.py deleted file mode 100644 index 37f24f7..0000000 --- a/submit/controllers/ui/new/reasons.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Descriptive user-firendly error explanations for process errors.""" - -from flask import url_for, Markup - -"""Attempt to convert short error messages into more user-friendly -warning messages. - -These messages are accompanied by user instructions (what to do) that -appear in the process.html template). - -NOTE: Move these into subdirectory at some point. Possibly as part of process - package. -""" - -SUCCESS_MARKUP = \ - Markup("We are processing your ui-app. This may take a minute or two." \ - " This page will refresh automatically every 5 seconds. You can " \ - " also refresh this page manually to check the current status. ") - -TEX_PRODUCED_MARKUP = \ - Markup("The ui-app PDF file appears to have been produced by TeX. " \ - "

    This file has been rejected as part your ui-app because " \ - "it appears to be pdf generated from TeX/LaTeX source. " \ - "For the reasons outlined at in the Why TeX FAQ we insist on " \ - "ui-app of the TeX source rather than the processed " \ - "version.

    Our software includes an automatic TeX " \ - "processing script that will produce PDF, PostScript and " \ - "dvi from your TeX source. If our determination that your " \ - "ui-app is TeX produced is incorrect, you should send " \ - "e-mail with your ui-app ID to " \ - 'arXiv administrators.

    ') - -DOCKER_ERROR_MARKUOP = \ - Markup("Our automatic TeX processing system has failed to launch. " \ - "There is a good cchance we are aware of the issue, but if the " \ - "problem persists you should send e-mail with your ui-app " \ - 'number to arXiv administrators.

    ') \ No newline at end of file diff --git a/submit/controllers/ui/new/tests/test_authorship.py b/submit/controllers/ui/new/tests/test_authorship.py deleted file mode 100644 index fcc40c5..0000000 --- a/submit/controllers/ui/new/tests/test_authorship.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for :mod:`submit.controllers.authorship`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from unittest import TestCase, mock - -from arxiv_auth import domain, auth -from pytz import timezone -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form - -import arxiv.submission as events -from arxiv.submission.domain.event import ConfirmAuthorship - -from submit.controllers.ui.new import authorship -from submit.routes.ui.flow_control import get_controllers_desire, STAGE_RESHOW - -class TestVerifyAuthorship(TestCase): - """Test behavior of :func:`.authorship` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - submitter_is_author=False) - mock_load.return_value = (before, []) - data, code, _ = authorship.authorship('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - params = MultiDict() - - with self.assertRaises(NotFound): - authorship.authorship('GET', params, self.session, submission_id) - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - submitter_is_author=False) - mock_load.return_value = (before, []) - params = MultiDict() - _, code, _ = authorship.authorship('POST', params, self.session, submission_id) - self.assertEqual(code, status.OK) - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_not_author_no_proxy(self, mock_load): - """User indicates they are not author, but also not proxy.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - submitter_is_author=False) - mock_load.return_value = (before, []) - params = MultiDict({'authorship': authorship.AuthorshipForm.NO}) - data, code, _ = authorship.authorship('POST', params, self.session, submission_id) - self.assertEqual(code, status.OK) - - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{authorship.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `authorship` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_is_author=False) - after = mock.MagicMock(submission_id=submission_id, is_finalized=False) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_url_for.return_value = 'https://foo.bar.com/yes' - - params = MultiDict({'authorship': 'y', 'action': 'next'}) - _, code, _ = authorship.authorship('POST', params, self.session, - submission_id) - self.assertEqual(code, status.SEE_OTHER, "Returns redirect") - - @mock.patch(f'{authorship.__name__}.AuthorshipForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{authorship.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_fails(self, mock_load, mock_save, mock_url_for): - """Event store flakes out on saving the command.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_is_author=False) - mock_load.return_value = (before, []) - - def raise_on_verify(*ev, **kwargs): - if type(ev[0]) is ConfirmAuthorship: - raise events.SaveError('The world is ending') - submission_id = kwargs.get('submission_id', 2) - return (mock.MagicMock(submission_id=submission_id), []) - - mock_save.side_effect = raise_on_verify - params = MultiDict({'authorship': 'y', 'action': 'next'}) - - try: - authorship.authorship('POST', params, self.session, 2) - self.fail('InternalServerError not raised') - except InternalServerError as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes form") diff --git a/submit/controllers/ui/new/tests/test_classification.py b/submit/controllers/ui/new/tests/test_classification.py deleted file mode 100644 index becbd52..0000000 --- a/submit/controllers/ui/new/tests/test_classification.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Tests for :mod:`submit.controllers.classification`.""" - -from unittest import TestCase, mock - -from arxiv_auth import domain, auth -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form -from http import HTTPStatus as status -import arxiv.submission as events -from submit.controllers.ui.new import classification - -from pytz import timezone -from datetime import timedelta, datetime - -class TestClassification(TestCase): - """Test behavior of :func:`.classification` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - data, code, _ = classification.classification('GET', MultiDict(), - self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - with self.assertRaises(NotFound): - classification.classification('GET', MultiDict(), self.session, - submission_id) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - - data, _, _ = classification.classification('POST', MultiDict(), self.session, - submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_with_invalid_category(self, mock_load, mock_save): - """POST request with invalid category.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - mock_save.return_value = (before, []) - - params = MultiDict({'category': 'astro-ph'}) # <- expired - - data, _, _ = classification.classification('POST', params, self.session, - submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_with_category(self, mock_load, mock_save): - """POST request with valid category.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - is_announced=False, version=1, arxiv_id=None) - mock_clsn = mock.MagicMock(category='astro-ph.CO') - after = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - primary_classification=mock_clsn, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - params = MultiDict({'category': 'astro-ph.CO'}) - data, code, _ = classification.classification('POST', params, - self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - - self.assertIsInstance(data['form'], Form, "Data includes a form") - - -class TestCrossList(TestCase): - """Test behavior of :func:`.cross_list` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - mock_clsn = mock.MagicMock(category='astro-ph.EP') - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - primary_classification=mock_clsn, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - params = MultiDict() - data, code, _ = classification.cross_list('GET', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - with self.assertRaises(NotFound): - classification.cross_list('GET', MultiDict(), self.session, - submission_id) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - mock_clsn = mock.MagicMock(category='astro-ph.EP') - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - primary_classification=mock_clsn, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - - data, _, _ = classification.cross_list('POST', MultiDict(), self.session, - submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_with_invalid_category(self, mock_load, mock_save): - """POST request with invalid category.""" - submission_id = 2 - mock_clsn = mock.MagicMock(category='astro-ph.EP') - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - primary_classification=mock_clsn, - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - mock_save.return_value = (before, []) - params = MultiDict({'category': 'astro-ph'}) # <- expired - data, _, _ = classification.classification('POST', params, self.session, - submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_with_category(self, mock_load, mock_save): - """POST request with valid category.""" - submission_id = 2 - mock_clsn = mock.MagicMock(category='astro-ph.EP') - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - primary_classification=mock_clsn, - primary_category='astro-ph.EP', - is_announced=False, version=1, arxiv_id=None) - after = mock.MagicMock(submission_id=submission_id, is_finalized=False, - primary_classification=mock_clsn, - primary_category='astro-ph.EP', - secondary_categories=[ - mock.MagicMock(category='astro-ph.CO') - ], - is_announced=False, version=1, arxiv_id=None) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - params = MultiDict({'category': 'astro-ph.CO'}) - data, code, _ = classification.cross_list('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_license.py b/submit/controllers/ui/new/tests/test_license.py deleted file mode 100644 index ed4646d..0000000 --- a/submit/controllers/ui/new/tests/test_license.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Tests for :mod:`submit.controllers.license`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from unittest import TestCase, mock - -from pytz import timezone -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form - -import arxiv.submission as events -from arxiv.submission.domain.event import SetLicense -from arxiv_auth import auth, domain - -from submit.controllers.ui.new import license - -from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS - -class TestSetLicense(TestCase): - """Test behavior of :func:`.license` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock(submission_id=submission_id), [] - ) - rdata, code, _ = license.license('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(rdata['form'], Form, "Data includes a form") - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - with self.assertRaises(NotFound): - license.license('GET', MultiDict(), self.session, submission_id) - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock(submission_id=submission_id), [] - ) - data, _, _ = license.license('POST', MultiDict(), self.session, submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{license.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `license` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - sub = mock.MagicMock(submission_id=submission_id, is_finalized=False) - mock_load.return_value = (sub, []) - mock_save.return_value = (sub, []) - # `url_for` returns a URL (unsurprisingly). - redirect_url = 'https://foo.bar.com/yes' - mock_url_for.return_value = redirect_url - - form_data = MultiDict({ - 'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', - 'action': 'next' - }) - data, code, headers = license.license('POST', form_data, self.session, - submission_id) - self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) - - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{license.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `license` set and same license already on ui-app.""" - submission_id = 2 - arxiv_lic = 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/' - lic = mock.MagicMock(uri=arxiv_lic) - sub = mock.MagicMock(submission_id=submission_id, - license=lic, - is_finalized=False) - mock_load.return_value = (sub, []) - mock_save.return_value = (sub, []) - mock_url_for.return_value = 'https://example.com/' - - form_data = MultiDict({ - 'license': arxiv_lic, - 'action': 'next' - }) - data, code, headers = license.license('POST', form_data, self.session, - submission_id) - self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) - - @mock.patch(f'{license.__name__}.LicenseForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{license.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_fails(self, mock_load, mock_save, mock_url_for): - """Event store flakes out on saving license selection.""" - submission_id = 2 - sub = mock.MagicMock(submission_id=submission_id, is_finalized=False) - mock_load.return_value = (sub, []) - - # Event store does not complain; returns object with `submission_id` - def raise_on_verify(*ev, **kwargs): - if type(ev[0]) is SetLicense: - raise events.SaveError('the sky is falling') - ident = kwargs.get('submission_id', 2) - return (mock.MagicMock(submission_id=ident), []) - - mock_save.side_effect = raise_on_verify - params = MultiDict({ - 'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', - 'action': 'next' - }) - try: - license.license('POST', params, self.session, 2) - self.fail('InternalServerError not raised') - except InternalServerError as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_metadata.py b/submit/controllers/ui/new/tests/test_metadata.py deleted file mode 100644 index 271fd32..0000000 --- a/submit/controllers/ui/new/tests/test_metadata.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for :mod:`submit.controllers.metadata`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from unittest import TestCase, mock - -from pytz import timezone -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms import Form - -import arxiv.submission as events -from arxiv.submission.domain.event import SetTitle, SetAbstract, SetAuthors, \ - SetReportNumber, SetMSCClassification, SetACMClassification, SetDOI, \ - SetJournalReference -from arxiv_auth import auth, domain - -from submit.controllers.ui.new import metadata - - -class TestOptional(TestCase): - """Tests for :func:`.optional`.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock(submission_id=submission_id), [] - ) - data, code, headers = metadata.optional( - 'GET', MultiDict(), self.session, submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, - "Response data includes a form") - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request_with_no_data(self, mock_load): - """POST request has no form data.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock(submission_id=submission_id), [] - ) - data, code, headers = metadata.optional( - 'POST', MultiDict(), self.session, submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - - self.assertIsInstance(data['form'], Form, - "Response data includes a form") - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_error_is_raised(self, mock_load, mock_save): - """POST request results in an SaveError exception.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock() - ) - mock_load.return_value = (mock_submission, []) - - def raise_save_error(*args, **kwargs): - raise events.SaveError('nope') - - mock_save.side_effect = raise_save_error - params = MultiDict({ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 12', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J26' - }) - with self.assertRaises(InternalServerError): - metadata.optional('POST', params, self.session, submission_id) - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_required_data(self, mock_load, mock_save): - """POST request with all fields.""" - submission_id = 2 - mock_submission = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock()) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 12', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J26' - }) - data, code, headers = metadata.optional('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - event_types = [type(ev) for ev in mock_save.call_args[0]] - self.assertIn(SetDOI, event_types, "Sets ui-app DOI") - self.assertIn(SetJournalReference, event_types, - "Sets journal references") - self.assertIn(SetReportNumber, event_types, - "Sets report number") - self.assertIn(SetACMClassification, event_types, - "Sets ACM classification") - self.assertIn(SetMSCClassification, event_types, - "Sets MSC classification") - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_unchanged_data(self, mock_load, mock_save): - """POST request with valid but unchanged data.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock(**{ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 12', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J26' - }) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 12', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J26' - }) - _, code, _ = metadata.optional('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertEqual(mock_save.call_count, 0, "No events are generated") - - @mock.patch(f'{metadata.__name__}.OptionalMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_some_changes(self, mock_load, mock_save): - """POST request with only some changed data.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock(**{ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 12', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J26' - }) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'doi': '10.0001/123456', - 'journal_ref': 'foo journal 10 2010: 12-345', - 'report_num': 'foo report 13', - 'acm_class': 'F.2.2; I.2.7', - 'msc_class': '14J27' - }) - _, code, _ = metadata.optional('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertEqual(mock_save.call_count, 1, "Events are generated") - - event_types = [type(ev) for ev in mock_save.call_args[0]] - self.assertIn(SetReportNumber, event_types, "Sets report_num") - self.assertIn(SetMSCClassification, event_types, "Sets msc") - self.assertEqual(len(event_types), 2, "Only two events are generated") - - -class TestMetadata(TestCase): - """Tests for :func:`.metadata`.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id) - mock_load.return_value = (before, []) - data, code, _ = metadata.metadata('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request_with_no_data(self, mock_load): - """POST request has no form data.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock(submission_id=submission_id), [] - ) - data, _, _ = metadata.metadata('POST', MultiDict(), self.session, submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_required_data(self, mock_load, mock_save): - """POST request with title, abstract, and author names.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock( - title='the old title', - abstract='not the abstract that you are looking for', - authors_display='bloggs, j' - ) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'title': 'a new, valid title', - 'abstract': 'this abstract is at least twenty characters long', - 'authors_display': 'j doe, j bloggs' - }) - _, code, _ = metadata.metadata('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - - event_types = [type(ev) for ev in mock_save.call_args[0]] - self.assertIn(SetTitle, event_types, "Sets ui-app title") - self.assertIn(SetAbstract, event_types, "Sets abstract") - self.assertIn(SetAuthors, event_types, "Sets authors") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_unchanged_data(self, mock_load, mock_save): - """POST request with valid but unaltered data.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock( - title='the old title', - abstract='not the abstract that you are looking for', - authors_display='bloggs, j' - ) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'title': 'the old title', - 'abstract': 'not the abstract that you are looking for', - 'authors_display': 'bloggs, j' - }) - _, code, _ = metadata.metadata('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertEqual(mock_save.call_count, 0, "No events are generated") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_some_changed_data(self, mock_load, mock_save): - """POST request with valid data; only the title has changed.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock( - title='the old title', - abstract='not the abstract that you are looking for', - authors_display='bloggs, j' - ) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'title': 'the new title', - 'abstract': 'not the abstract that you are looking for', - 'authors_display': 'bloggs, j' - }) - _, code, _ = metadata.metadata('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertEqual(mock_save.call_count, 1, "One event is generated") - self.assertIsInstance(mock_save.call_args[0][0], SetTitle, - "SetTitle is generated") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_invalid_data(self, mock_load, mock_save): - """POST request with invalid data.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock( - title='the old title', - abstract='not the abstract that you are looking for', - authors_display='bloggs, j' - ) - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - params = MultiDict({ - 'title': 'the new title', - 'abstract': 'too short', - 'authors_display': 'bloggs, j' - }) - data, _, _ = metadata.metadata('POST', params, self.session, submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{metadata.__name__}.CoreMetadataForm.Meta.csrf', False) - @mock.patch(f'{metadata.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_error_is_raised(self, mock_load, mock_save): - """POST request results in an SaveError exception.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - is_finalized=False, - metadata=mock.MagicMock( - title='the old title', - abstract='not the abstract that you are looking for', - authors_display='bloggs, j' - ) - ) - mock_load.return_value = (mock_submission, []) - - def raise_save_error(*args, **kwargs): - raise events.SaveError('nope') - - mock_save.side_effect = raise_save_error - params = MultiDict({ - 'title': 'a new, valid title', - 'abstract': 'this abstract is at least twenty characters long', - 'authors_display': 'j doe, j bloggs' - }) - with self.assertRaises(InternalServerError): - metadata.metadata('POST', params, self.session, submission_id) diff --git a/submit/controllers/ui/new/tests/test_policy.py b/submit/controllers/ui/new/tests/test_policy.py deleted file mode 100644 index 36c5051..0000000 --- a/submit/controllers/ui/new/tests/test_policy.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for :mod:`submit.controllers.policy`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from unittest import TestCase, mock - -from pytz import timezone -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form - -import arxiv.submission as events -from arxiv.submission.domain.event import ConfirmPolicy -from arxiv_auth import auth, domain -from submit.controllers.ui.new import policy - -from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS - -class TestConfirmPolicy(TestCase): - """Test behavior of :func:`.policy` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=False) - mock_load.return_value = (before, []) - data = MultiDict() - - data, code, _ = policy.policy('GET', data, self.session, submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - with self.assertRaises(NotFound): - policy.policy('GET', MultiDict(), self.session, submission_id) - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=False) - mock_load.return_value = (before, []) - - params = MultiDict() - data, _, _ = policy.policy('POST', params, self.session, submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_not_author_no_proxy(self, mock_load): - """User indicates they are not author, but also not proxy.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=False) - mock_load.return_value = (before, []) - params = MultiDict({}) - data, _, _ = policy.policy('POST', params, self.session, submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{policy.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `policy` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=False) - after = mock.MagicMock(submission_id=submission_id, is_finalized=False) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_url_for.return_value = 'https://foo.bar.com/yes' - - params = MultiDict({'policy': 'y', 'action': 'next'}) - data, code, _ = policy.policy('POST', params, self.session, submission_id) - self.assertEqual(code, status.OK) - self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) - - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{policy.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data_already_accepted(self, mock_load, mock_save, mock_url_for): - """POST request with `policy` y and already set on the ui-app.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=True) - after = mock.MagicMock(submission_id=submission_id, is_finalized=False) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_url_for.return_value = 'https://foo.bar.com/yes' - - params = MultiDict({'policy': 'y', 'action': 'next'}) - data, code, _ = policy.policy('POST', params, self.session, submission_id) - self.assertEqual(code, status.OK) - self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) - - @mock.patch(f'{policy.__name__}.PolicyForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{policy.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_fails(self, mock_load, mock_save, mock_url_for): - """Event store flakes out on saving policy acceptance.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_accepts_policy=False) - mock_load.return_value = (before, []) - - # Event store does not complain; returns object with `submission_id` - def raise_on_policy(*ev, **kwargs): - if type(ev[0]) is ConfirmPolicy: - raise events.SaveError('the end of the world as we know it') - ident = kwargs.get('submission_id', 2) - return (mock.MagicMock(submission_id=ident), []) - - mock_save.side_effect = raise_on_policy - params = MultiDict({'policy': 'y', 'action': 'next'}) - try: - policy.policy('POST', params, self.session, 2) - self.fail('InternalServerError not raised') - except InternalServerError as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_primary.py b/submit/controllers/ui/new/tests/test_primary.py deleted file mode 100644 index f97d011..0000000 --- a/submit/controllers/ui/new/tests/test_primary.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Tests for :mod:`submit.controllers.classification`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from unittest import TestCase, mock - -from pytz import timezone -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import Form - -import arxiv.submission as events -from arxiv.submission.domain.event import SetPrimaryClassification -from submit.controllers.ui.new import classification - -from arxiv_auth import auth, domain -from submit.routes.ui.flow_control import get_controllers_desire, STAGE_SUCCESS - -class TestSetPrimaryClassification(TestCase): - """Test behavior of :func:`.classification` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, submitter_is_author=False, - is_finalized=False, version=1) - mock_load.return_value = (before, []) - params = MultiDict() - data, code, _ = classification.classification('GET', params, - self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_nonexistant_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - - def raise_no_such_submission(*args, **kwargs): - raise events.exceptions.NoSuchSubmission('Nada') - - mock_load.side_effect = raise_no_such_submission - with self.assertRaises(NotFound): - classification.classification('GET', MultiDict(), self.session, - submission_id) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, submitter_is_author=False, - is_finalized=False, version=1) - mock_load.return_value = (before, []) - data, _, _ = classification.classification('POST', MultiDict(), self.session, - submission_id) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `classification` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, submitter_is_author=False, - is_finalized=False, version=1) - mock_clsn = mock.MagicMock(category='astro-ph.CO') - after = mock.MagicMock(submission_id=submission_id, is_announced=False, - arxiv_id=None, submitter_is_author=False, - primary_classification=mock_clsn, - is_finalized=False, version=1) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_url_for.return_value = 'https://foo.bar.com/yes' - - params = MultiDict({'category': 'astro-ph.CO', 'action': 'next'}) - data, code, _ = classification.classification('POST', params, - self.session, submission_id) - self.assertEqual(get_controllers_desire(data), STAGE_SUCCESS) - - @mock.patch(f'{classification.__name__}.ClassificationForm.Meta.csrf', - False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{classification.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_error(self, mock_load, mock_save, mock_url_for): - """Event store flakes out on saving classification event.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, submitter_is_author=False, - is_finalized=False, version=1) - mock_load.return_value = (before, []) - - # Event store does not complain; returns object with `submission_id` - def raise_on_set(*ev, **kwargs): - if type(ev[0]) is SetPrimaryClassification: - raise events.SaveError('never get back') - ident = kwargs.get('submission_id', 2) - return (mock.MagicMock(submission_id=ident), []) - - mock_save.side_effect = raise_on_set - params = MultiDict({'category': 'astro-ph.CO', 'action': 'next'}) - try: - classification.classification('POST', params, self.session, 2) - self.fail('InternalServerError not raised') - except InternalServerError as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/tests/test_unsubmit.py b/submit/controllers/ui/new/tests/test_unsubmit.py deleted file mode 100644 index ea9294e..0000000 --- a/submit/controllers/ui/new/tests/test_unsubmit.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tests for :mod:`submit.controllers.unsubmit`.""" - -from unittest import TestCase, mock -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest -from wtforms import Form -from http import HTTPStatus as status -from submit.controllers.ui.new import unsubmit - -from pytz import timezone -from datetime import timedelta, datetime -from arxiv_auth import auth, domain - - -class TestUnsubmit(TestCase): - """Test behavior of :func:`.unsubmit` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=True, - submitter_contact_verified=False) - mock_load.return_value = (before, []) - data, code, _ = unsubmit.unsubmit('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=True, - submitter_contact_verified=False) - mock_load.return_value = (before, []) - params = MultiDict() - try: - unsubmit.unsubmit('POST', params, self.session, submission_id) - self.fail('BadRequest not raised') - except BadRequest as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{unsubmit.__name__}.UnsubmitForm.Meta.csrf', False) - @mock.patch(f'{unsubmit.__name__}.url_for') - @mock.patch('arxiv.base.alerts.flash_success') - @mock.patch(f'{unsubmit.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, - mock_flash_success, mock_url_for): - """POST request with `confirmed` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=True, is_announced=False) - after = mock.MagicMock(submission_id=submission_id, - is_finalized=False, is_announced=False) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_flash_success.return_value = None - mock_url_for.return_value = 'https://foo.bar.com/yes' - - form_data = MultiDict({'confirmed': True}) - _, code, _ = unsubmit.unsubmit('POST', form_data, self.session, - submission_id) - self.assertEqual(code, status.SEE_OTHER, "Returns redirect") diff --git a/submit/controllers/ui/new/tests/test_upload.py b/submit/controllers/ui/new/tests/test_upload.py deleted file mode 100644 index 5dd2e71..0000000 --- a/submit/controllers/ui/new/tests/test_upload.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Tests for :mod:`submit.controllers.upload`.""" - -from datetime import timedelta, datetime -from http import HTTPStatus as status -from pytz import timezone -from unittest import TestCase, mock - -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest, InternalServerError -from wtforms import Form - -from arxiv_auth import auth, domain -from arxiv.submission.domain.submission import SubmissionContent -from arxiv.submission.domain.uploads import Upload, FileStatus, FileError, \ - UploadLifecycleStates, UploadStatus -from arxiv.submission.services import filemanager - -from submit.controllers.ui.new import upload -from submit.controllers.ui.new import upload_delete - -from submit.routes.ui.flow_control import STAGE_SUCCESS, \ - get_controllers_desire, STAGE_RESHOW - -class TestUpload(TestCase): - """Tests for :func:`submit.controllers.upload`.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName('Jane', 'Bloggs', 'III'), - profile=domain.UserProfile( - affiliation='FSU', - rank=3, - country='de', - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_no_upload(self, mock_load): - """GET request for ui-app with no upload package.""" - submission_id = 2 - subm = mock.MagicMock(submission_id=submission_id, source_content=None, - is_finalized=False, is_announced=False, - arxiv_id=None, version=1) - mock_load.return_value = (subm, []) - params = MultiDict({}) - files = MultiDict({}) - data, code, _ = upload.upload_files('GET', params, files, self.session, - submission_id, 'footoken') - self.assertEqual(code, status.OK, 'Returns 200 OK') - self.assertIn('ui-app', data, 'Submission is in response') - self.assertIn('submission_id', data, 'ID is in response') - - @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) - @mock.patch(f'{upload.__name__}.alerts', mock.MagicMock()) - @mock.patch(f'{upload.__name__}.Filemanager') - @mock.patch('arxiv.submission.load') - def test_get_upload(self, mock_load, mock_Filemanager): - """GET request for ui-app with an existing upload package.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier='5433', - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, - version=1 - ), [] - ) - mock_filemanager = mock.MagicMock() - mock_filemanager.get_upload_status.return_value = ( - Upload( - identifier=25, - checksum='a1s2d3f4', - size=593920, - started=datetime.now(), - completed=datetime.now(), - created=datetime.now(), - modified=datetime.now(), - status=UploadStatus.READY, - lifecycle=UploadLifecycleStates.ACTIVE, - locked=False, - files=[FileStatus( - path='', - name='thebestfile.pdf', - file_type='PDF', - modified=datetime.now(), - size=20505, - ancillary=False, - errors=[] - )], - errors=[] - ) - ) - mock_Filemanager.current_session.return_value = mock_filemanager - params = MultiDict({}) - files = MultiDict({}) - data, code, _ = upload.upload_files('GET', params, self.session, - submission_id, files=files, - token='footoken') - self.assertEqual(code, status.OK, 'Returns 200 OK') - self.assertEqual(mock_filemanager.get_upload_status.call_count, 1, - 'Calls the file management service') - self.assertIn('status', data, 'Upload status is in response') - self.assertIn('ui-app', data, 'Submission is in response') - self.assertIn('submission_id', data, 'ID is in response') - - @mock.patch(f'{upload.__name__}.UploadForm.Meta.csrf', False) - @mock.patch(f'{upload.__name__}.alerts', mock.MagicMock()) - @mock.patch(f'{upload.__name__}.url_for', mock.MagicMock(return_value='/')) - @mock.patch(f'{upload.__name__}.Filemanager') - @mock.patch(f'{upload.__name__}.save') - @mock.patch(f'arxiv.submission.load') - def test_post_upload(self, mock_load, mock_save, mock_filemanager): - """POST request for ui-app with an existing upload package.""" - submission_id = 2 - mock_submission = mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier='5433', - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, version=1 - ) - mock_load.return_value = (mock_submission, []) - mock_save.return_value = (mock_submission, []) - mock_fm = mock.MagicMock() - mock_fm.add_file.return_value = Upload( - identifier=25, - checksum='a1s2d3f4', - size=593920, - started=datetime.now(), - completed=datetime.now(), - created=datetime.now(), - modified=datetime.now(), - status=UploadStatus.READY, - lifecycle=UploadLifecycleStates.ACTIVE, - locked=False, - files=[FileStatus( - path='', - name='thebestfile.pdf', - file_type='PDF', - modified=datetime.now(), - size=20505, - ancillary=False, - errors=[] - )], - errors=[] - ) - mock_filemanager.current_session.return_value = mock_fm - params = MultiDict({}) - mock_file = mock.MagicMock() - files = MultiDict({'file': mock_file}) - data, code, _ = upload.upload_files('POST', params, self.session, - submission_id, files=files, - token='footoken') - - self.assertEqual(code, status.OK) - self.assertEqual(get_controllers_desire(data), STAGE_RESHOW, - 'Successful upload and reshow form') - self.assertEqual(mock_fm.add_file.call_count, 1, - 'Calls the file management service') - self.assertTrue(mock_filemanager.add_file.called_with(mock_file)) - - -class TestDelete(TestCase): - """Tests for :func:`submit.controllers.upload.delete`.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName('Jane', 'Bloggs', 'III'), - profile=domain.UserProfile( - affiliation='FSU', - rank=3, - country='de', - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) - @mock.patch(f'{upload_delete.__name__}.Filemanager') - @mock.patch('arxiv.submission.load') - def test_get_delete(self, mock_load, mock_filemanager): - """GET request to delete a file.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier='5433', - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, - version=1 - ), [] - ) - file_path = 'anc/foo.jpeg' - params = MultiDict({'path': file_path}) - data, code, _ = upload_delete.delete_file('GET', params, self.session, - submission_id, 'footoken') - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIn('form', data, "Returns a form in response") - self.assertEqual(data['form'].file_path.data, file_path, 'Path is set') - - @mock.patch(f'{upload_delete.__name__}.alerts', mock.MagicMock()) - @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) - @mock.patch(f'{upload_delete.__name__}.Filemanager') - @mock.patch('arxiv.submission.load') - def test_post_delete(self, mock_load, mock_filemanager): - """POST request to delete a file without confirmation.""" - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier='5433', - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, - version=1 - ), [] - ) - file_path = 'anc/foo.jpeg' - params = MultiDict({'file_path': file_path}) - try: - upload_delete.delete_file('POST', params, self.session, submission_id, 'tok') - except BadRequest as e: - data = e.description - self.assertIn('form', data, "Returns a form in response") - - @mock.patch(f'{upload_delete.__name__}.alerts', mock.MagicMock()) - @mock.patch(f'{upload_delete.__name__}.DeleteFileForm.Meta.csrf', False) - @mock.patch(f'{upload_delete.__name__}.url_for') - @mock.patch(f'{upload_delete.__name__}.Filemanager') - @mock.patch(f'{upload_delete.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_delete_confirmed(self, mock_load, mock_save, - mock_filemanager, mock_url_for): - """POST request to delete a file without confirmation.""" - redirect_uri = '/foo' - mock_url_for.return_value = redirect_uri - upload_id = '5433' - submission_id = 2 - mock_load.return_value = ( - mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier=upload_id, - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, - version=1 - ), [] - ) - mock_save.return_value = ( - mock.MagicMock( - submission_id=submission_id, - source_content=SubmissionContent( - identifier=upload_id, - checksum='a1s2d3f4', - uncompressed_size=593920, - compressed_size=1000, - source_format=SubmissionContent.Format.TEX - ), - is_finalized=False, is_announced=False, arxiv_id=None, - version=1 - ), [] - ) - file_path = 'anc/foo.jpeg' - params = MultiDict({'file_path': file_path, 'confirmed': True}) - data, code, _ = upload_delete.delete_file('POST', params, self.session, submission_id, - 'footoken') - self.assertTrue( - mock_filemanager.delete_file.called_with(upload_id, file_path), - "Delete file method of file manager service is called" - ) - self.assertEqual(code, status.OK) - self.assertTrue(get_controllers_desire(data), STAGE_SUCCESS) diff --git a/submit/controllers/ui/new/tests/test_verify_user.py b/submit/controllers/ui/new/tests/test_verify_user.py deleted file mode 100644 index 6576cbd..0000000 --- a/submit/controllers/ui/new/tests/test_verify_user.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for :mod:`submit.controllers.verify_user`.""" - -from unittest import TestCase, mock -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, BadRequest -from wtforms import Form -from http import HTTPStatus as status -import arxiv.submission as events -from arxiv.submission.domain.event import ConfirmContactInformation -from submit.controllers.ui.new import verify_user - -from pytz import timezone -from datetime import timedelta, datetime -from arxiv_auth import auth, domain - - -class TestVerifyUser(TestCase): - """Test behavior of :func:`.verify_user` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_get_request_with_submission(self, mock_load): - """GET request with a ui-app ID.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_contact_verified=False) - mock_load.return_value = (before, []) - data, code, _ = verify_user.verify('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_post_request(self, mock_load): - """POST request with no data.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_contact_verified=False) - mock_load.return_value = (before, []) - params = MultiDict() - data, code, _ = verify_user.verify('POST', params, self.session, - submission_id) - self.assertEqual(code, status.OK) - self.assertIsInstance(data['form'], Form, "Data includes a form") - - @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{verify_user.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_post_request_with_data(self, mock_load, mock_save, mock_url_for): - """POST request with `verify_user` set.""" - # Event store does not complain; returns object with `submission_id`. - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_contact_verified=False) - after = mock.MagicMock(submission_id=submission_id, is_finalized=False, - submitter_contact_verified=True) - mock_load.return_value = (before, []) - mock_save.return_value = (after, []) - mock_url_for.return_value = 'https://foo.bar.com/yes' - - form_data = MultiDict({'verify_user': 'y', 'action': 'next'}) - _, code, _ = verify_user.verify('POST', form_data, self.session, - submission_id) - self.assertEqual(code, status.OK,) - - @mock.patch(f'{verify_user.__name__}.VerifyUserForm.Meta.csrf', False) - @mock.patch('submit.controllers.ui.util.url_for') - @mock.patch(f'{verify_user.__name__}.save') - @mock.patch('arxiv.submission.load') - def test_save_fails(self, mock_load, mock_save, mock_url_for): - """Event store flakes out saving authorship verification.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_finalized=False, - submitter_contact_verified=False) - mock_load.return_value = (before, []) - - # Event store does not complain; returns object with `submission_id` - def raise_on_verify(*ev, **kwargs): - if type(ev[0]) is ConfirmContactInformation: - raise events.SaveError('not today') - ident = kwargs.get('submission_id', 2) - return (mock.MagicMock(submission_id=ident, - submitter_contact_verified=False), []) - - mock_save.side_effect = raise_on_verify - params = MultiDict({'verify_user': 'y', 'action': 'next'}) - try: - verify_user.verify('POST', params, self.session, 2) - self.fail('InternalServerError not raised') - except InternalServerError as e: - data = e.description - self.assertIsInstance(data['form'], Form, "Data includes a form") diff --git a/submit/controllers/ui/new/unsubmit.py b/submit/controllers/ui/new/unsubmit.py deleted file mode 100644 index 01c5fc2..0000000 --- a/submit/controllers/ui/new/unsubmit.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Provide the controller used to unsubmit/unfinalize a ui-app.""" - -from http import HTTPStatus as status - -from flask import url_for -from wtforms import BooleanField, validators -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest, InternalServerError - -from arxiv.base import alerts -from arxiv.forms import csrf -from arxiv.submission import save -from arxiv.submission.domain.event import UnFinalizeSubmission -from arxiv_auth.domain import Session - -from submit.controllers.ui.util import Response, user_and_client_from_session, validate_command -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - -class UnsubmitForm(csrf.CSRFForm): - """Form for unsubmitting a ui-app.""" - - confirmed = BooleanField('Confirmed', - validators=[validators.DataRequired()]) - - -def unsubmit(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Unsubmit a ui-app.""" - submission, submission_events = load_submission(submission_id) - response_data = { - 'ui-app': submission, - 'submission_id': submission.submission_id, - } - - if method == 'GET': - form = UnsubmitForm() - response_data.update({'form': form}) - return response_data, status.OK, {} - elif method == 'POST': - form = UnsubmitForm(params) - response_data.update({'form': form}) - if form.validate() and form.confirmed.data: - user, client = user_and_client_from_session(session) - command = UnFinalizeSubmission(creator=user, client=client) - if not validate_command(form, command, submission, 'confirmed'): - raise BadRequest(response_data) - - try: - save(command, submission_id=submission_id) - except Exception as e: - alerts.flash_failure("Whoops!") - raise InternalServerError(response_data) from e - alerts.flash_success("Unsubmitted.") - redirect = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': redirect} - response_data.update({'form': form}) - # TODO not updated to non-BadRequest convention - raise BadRequest(response_data) diff --git a/submit/controllers/ui/new/upload.py b/submit/controllers/ui/new/upload.py deleted file mode 100644 index 3efc076..0000000 --- a/submit/controllers/ui/new/upload.py +++ /dev/null @@ -1,548 +0,0 @@ -""" -Controllers for upload-related requests. - -Things that still need to be done: - -- Display error alerts from the file management service. -- Show warnings/errors for individual files in the table. We may need to - extend the flashing mechanism to "flash" data to the next page (without - displaying it as a notification to the user). - -""" - -import traceback -from collections import OrderedDict -from http import HTTPStatus as status -from locale import strxfrm -from typing import Tuple, Dict, Any, Optional, List, Union, Mapping - -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import ( - InternalServerError, - BadRequest, - MethodNotAllowed, - RequestEntityTooLarge -) -from wtforms import BooleanField, HiddenField, FileField -from wtforms.validators import DataRequired - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv.integration.api import exceptions -from arxiv.submission import save, Submission, User, Client, Event -from arxiv.submission.services import Filemanager -from arxiv.submission.domain.uploads import Upload, FileStatus, UploadStatus -from arxiv.submission.domain.submission import SubmissionContent -from arxiv.submission.domain.event import SetUploadPackage, UpdateUploadPackage -from arxiv.submission.exceptions import SaveError -from arxiv_auth.domain import Session - -from submit.controllers.ui.util import ( - validate_command, - user_and_client_from_session -) -from submit.util import load_submission, tidy_filesize -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage -from submit.controllers.ui.util import add_immediate_alert - -logger = logging.getLogger(__name__) - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - -PLEASE_CONTACT_SUPPORT = Markup( - 'If you continue to experience problems, please contact' - ' arXiv support.' -) - - -def upload_files(method: str, params: MultiDict, session: Session, - submission_id: int, files: Optional[MultiDict] = None, - token: Optional[str] = None, **kwargs) -> Response: - """Handle a file upload request. - - GET requests are treated as a request for information about the current - state of the ui-app upload. - - POST requests are treated either as package upload if the upload - workspace does not already exist or a request to replace a file. - - Parameters - ---------- - method : str - ``GET`` or ``POST`` - params : :class:`MultiDict` - The form data from the request. - files : :class:`MultiDict` - File data in the multipart request. Values should be - :class:`FileStorage` instances. - session : :class:`Session` - The authenticated session for the request. - submission_id : int - The identifier of the ui-app for which the upload is being made. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``200`` or ``303``, unless something - goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response, if - applicable. - - """ - rdata = {} - if files is None or token is None: - add_immediate_alert(rdata, alerts.FAILURE, - 'Missing auth files or token') - return stay_on_this_stage((rdata, status.OK, {})) - - submission, _ = load_submission(submission_id) - - rdata.update({'submission_id': submission_id, - 'ui-app': submission, - 'form': UploadForm()}) - - if method not in ['GET', 'POST']: - raise MethodNotAllowed() - elif method == 'GET': - logger.debug('GET; load current upload state') - return _get_upload(params, session, submission, rdata, token) - elif method == 'POST': - try: # Make sure that we have a file to work with. - pointer = files['file'] - except KeyError: # User is going back, saving/exiting, or next step. - pointer = None - - if not pointer: - # Don't flash a message if the user is just trying to go back to the - # previous page. - logger.debug('No files on request') - action = params.get('action', None) - if action: - logger.debug('User is navigating away from upload UI') - return {}, status.SEE_OTHER, {} - else: - return stay_on_this_stage(_get_upload(params, session, - submission, rdata, token)) - - try: - if submission.source_content is None: - logger.debug('New upload package') - return _new_upload(params, pointer, session, submission, rdata, token) - else: - logger.debug('Adding additional files') - return _new_file(params, pointer, session, submission, rdata, token) - except exceptions.ConnectionFailed as ex: - logger.debug('Problem POSTing upload: %s', ex) - alerts.flash_failure(Markup( - 'There was a problem uploading your file. ' - f'{PLEASE_CONTACT_SUPPORT}')) - except RequestEntityTooLarge as ex: - logger.debug('Problem POSTing upload: %s', ex) - alerts.flash_failure(Markup( - 'There was a problem uploading your file because it exceeds ' - f'our maximum size limit. {PLEASE_CONTACT_SUPPORT}')) - return stay_on_this_stage(_get_upload(params, session, submission, rdata, token)) - - -class UploadForm(csrf.CSRFForm): - """Form for uploading files.""" - - file = FileField('Choose a file...') - ancillary = BooleanField('Ancillary') - - -def _update(form: UploadForm, submission: Submission, stat: Upload, - submitter: User, client: Optional[Client] = None) \ - -> Optional[Submission]: - """ - Update the :class:`.Submission` after an upload-related action. - - The ui-app is linked to the upload workspace via the - :attr:`Submission.source_content` attribute. This is set using a - :class:`SetUploadPackage` command. If the workspace identifier changes - (e.g. on first upload), we want to execute :class:`SetUploadPackage` to - make the association. - - Parameters - ---------- - form : WTForm for adding validation error messages - submission : :class:`Submission` - stat : :class:`Upload` - submitter : :class:`User` - client : :class:`Client` or None - - """ - existing_upload = getattr(submission.source_content, 'identifier', None) - - command: Event - if existing_upload == stat.identifier: - command = UpdateUploadPackage(creator=submitter, client=client, - checksum=stat.checksum, - uncompressed_size=stat.size, - compressed_size=stat.compressed_size, - source_format=stat.source_format) - else: - command = SetUploadPackage(creator=submitter, client=client, - identifier=stat.identifier, - checksum=stat.checksum, - compressed_size=stat.compressed_size, - uncompressed_size=stat.size, - source_format=stat.source_format) - - if not validate_command(form, command, submission): - return None - - try: - submission, _ = save(command, submission_id=submission.submission_id) - except SaveError: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - return submission - - -def _get_upload(params: MultiDict, session: Session, submission: Submission, - rdata: Dict[str, Any], token) -> Response: - """ - Get the current state of the upload workspace, and prepare a response. - - Parameters - ---------- - params : :class:`MultiDict` - The query parameters from the request. - session : :class:`Session` - The authenticated session for the request. - submission : :class:`Submission` - The ui-app for which to retrieve upload workspace information. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. - dict - Extra headers to add/update on the response. - - """ - rdata.update({'status': None, 'form': UploadForm()}) - - if submission.source_content is None: - # Nothing to show; should generate a blank-slate upload screen. - return rdata, status.OK, {} - - fm = Filemanager.current_session() - - upload_id = submission.source_content.identifier - status_data = alerts.get_hidden_alerts('_status') - if type(status_data) is dict and status_data['identifier'] == upload_id: - stat = Upload.from_dict(status_data) - else: - try: - stat = fm.get_upload_status(upload_id, token) - except exceptions.RequestFailed as ex: - # TODO: handle specific failure cases. - logger.debug('Failed to get upload status: %s', ex) - logger.error(traceback.format_exc()) - raise InternalServerError(rdata) from ex - rdata.update({'status': stat}) - if stat: - rdata.update({'immediate_notifications': _get_notifications(stat)}) - return rdata, status.OK, {} - - -def _new_upload(params: MultiDict, pointer: FileStorage, session: Session, - submission: Submission, rdata: Dict[str, Any], token: str) \ - -> Response: - """ - Handle a POST request with a new upload package. - - This occurs in the case that there is not already an upload workspace - associated with the ui-app. See the :attr:`Submission.source_content` - attribute, which is set using :class:`SetUploadPackage`. - - Parameters - ---------- - params : :class:`MultiDict` - The form data from the request. - pointer : :class:`FileStorage` - The file upload stream. - session : :class:`Session` - The authenticated session for the request. - submission : :class:`Submission` - The ui-app for which the upload is being made. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``303``, unless something goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response. - - """ - submitter, client = user_and_client_from_session(session) - fm = Filemanager.current_session() - - params['file'] = pointer - form = UploadForm(params) - rdata.update({'form': form}) - - if not form.validate(): - logger.debug('Invalid form data') - return stay_on_this_stage((rdata, status.OK, {})) - - try: - stat = fm.upload_package(pointer, token) - except exceptions.RequestFailed as ex: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - logger.debug('Failed to upload package: %s', ex) - logger.error(traceback.format_exc()) - raise InternalServerError(rdata) from ex - - submission = _update(form, submission, stat, submitter, client) - converted_size = tidy_filesize(stat.size) - if stat.status is UploadStatus.READY: - alerts.flash_success( - f'Unpacked {stat.file_count} files. Total ui-app' - f' package size is {converted_size}', - title='Upload successful' - ) - elif stat.status is UploadStatus.READY_WITH_WARNINGS: - alerts.flash_warning( - f'Unpacked {stat.file_count} files. Total ui-app' - f' package size is {converted_size}. See below for warnings.', - title='Upload complete, with warnings' - ) - elif stat.status is UploadStatus.ERRORS: - alerts.flash_warning( - f'Unpacked {stat.file_count} files. Total ui-app' - f' package size is {converted_size}. See below for errors.', - title='Upload complete, with errors' - ) - alerts.flash_hidden(stat.to_dict(), '_status') - - rdata.update({'status': stat}) - return stay_on_this_stage((rdata, status.OK, {})) - -# loc = url_for('ui.file_upload', submission_id=ui-app.submission_id) -# return {}, status.SEE_OTHER, {'Location': loc} - - -def _new_file(params: MultiDict, pointer: FileStorage, session: Session, - submission: Submission, rdata: Dict[str, Any], token: str) \ - -> Response: - """ - Handle a POST request with a new file to add to an existing upload package. - - This occurs in the case that there is already an upload workspace - associated with the ui-app. See the :attr:`Submission.source_content` - attribute, which is set using :class:`SetUploadPackage`. - - Parameters - ---------- - params : :class:`MultiDict` - The form data from the request. - pointer : :class:`FileStorage` - The file upload stream. - session : :class:`Session` - The authenticated session for the request. - submission : :class:`Submission` - The ui-app for which the upload is being made. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``303``, unless something goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response. - - """ - submitter, client = user_and_client_from_session(session) - fm = Filemanager.current_session() - upload_id = submission.source_content.identifier - - # Using a form object provides some extra assurance that this is a legit - # request; provides CSRF goodies. - params['file'] = pointer - form = UploadForm(params) - rdata.update({'form': form, 'ui-app': submission}) - - if not form.validate(): - logger.error('Invalid upload form: %s', form.errors) - alerts.flash_failure( - "No file was uploaded; please try again.", - title="Something went wrong") - return stay_on_this_stage((rdata, status.OK, {})) - - ancillary: bool = form.ancillary.data - - try: - stat = fm.add_file(upload_id, pointer, token, ancillary=ancillary) - except exceptions.RequestFailed as ex: - try: - ex_data = ex.response.json() - except Exception: - ex_data = None - if ex_data is not None and 'reason' in ex_data: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request:' - f' {ex_data["reason"]}. {PLEASE_CONTACT_SUPPORT}' - )) - return stay_on_this_stage((rdata, status.OK, {})) - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - logger.debug('Failed to add file: %s', ) - logger.error(traceback.format_exc()) - raise InternalServerError(rdata) from ex - - submission = _update(form, submission, stat, submitter, client) - converted_size = tidy_filesize(stat.size) - if stat.status is UploadStatus.READY: - alerts.flash_success( - f'Uploaded {pointer.filename} successfully. Total ui-app' - f' package size is {converted_size}', - title='Upload successful' - ) - elif stat.status is UploadStatus.READY_WITH_WARNINGS: - alerts.flash_warning( - f'Uploaded {pointer.filename} successfully. Total ui-app' - f' package size is {converted_size}. See below for warnings.', - title='Upload complete, with warnings' - ) - elif stat.status is UploadStatus.ERRORS: - alerts.flash_warning( - f'Uploaded {pointer.filename} successfully. Total ui-app' - f' package size is {converted_size}. See below for errors.', - title='Upload complete, with errors' - ) - status_data = stat.to_dict() - alerts.flash_hidden(status_data, '_status') - rdata.update({'status': stat}) - return stay_on_this_stage((rdata, status.OK, {})) - - -def _get_notifications(stat: Upload) -> List[Dict[str, str]]: - # TODO: these need wordsmithing. - notifications = [] - if not stat.files: # Nothing in the upload workspace. - return notifications - if stat.status is UploadStatus.ERRORS: - notifications.append({ - 'title': 'Unresolved errors', - 'severity': 'danger', - 'body': 'There are unresolved problems with your ui-app' - ' files. Please correct the errors below before' - ' proceeding.' - }) - elif stat.status is UploadStatus.READY_WITH_WARNINGS: - notifications.append({ - 'title': 'Warnings', - 'severity': 'warning', - 'body': 'There is one or more unresolved warning in the file list.' - ' You may proceed with your ui-app, but please note' - ' that these issues may cause delays in processing' - ' and/or announcement.' - }) - if stat.source_format is SubmissionContent.Format.UNKNOWN: - notifications.append({ - 'title': 'Unknown ui-app type', - 'severity': 'warning', - 'body': 'We could not determine the source type of your' - ' ui-app. Please check your files carefully. We may' - ' not be able to process your files.' - }) - elif stat.source_format is SubmissionContent.Format.INVALID: - notifications.append({ - 'title': 'Unsupported ui-app type', - 'severity': 'danger', - 'body': 'It is likely that your ui-app content is not' - ' supported. Please check your files carefully. We may not' - ' be able to process your files.' - }) - else: - notifications.append({ - 'title': f'Detected {stat.source_format.value.upper()}', - 'severity': 'success', - 'body': 'Your ui-app content is supported.' - }) - return notifications - - -def group_files(files: List[FileStatus]) -> OrderedDict: - """Group a set of file status objects by directory structure. - - Parameters - ---------- - list - Elements are :class:`FileStatus` objects. - - Returns ------- :class:`OrderedDict` Keys are strings of either - file or directory names. Values are either :class:`FileStatus` - instances (leaves) or :class:`OrderedDict` (containing more - :class:`FileStatus` and/or :class:`OrderedDict`, etc). - - """ - # First step is to organize by file tree. - tree = {} - for f in files: - parts = f.path.split('/') - if len(parts) == 1: - tree[f.name] = f - else: - subtree = tree - for part in parts[:-1]: - if part not in subtree: - subtree[part] = {} - subtree = subtree[part] - subtree[parts[-1]] = f - - # Reorder subtrees for nice display. - def _order(node: Union[dict, FileStatus]) -> OrderedDict: - if type(node) is FileStatus: - return node - - in_subtree: dict = node - - # split subtree into FileStatus and other - filestats = [fs for key, fs in in_subtree.items() - if type(fs) is FileStatus] - deeper_subtrees = [(key, st) for key, st in in_subtree.items() - if type(st) is not FileStatus] - - # add the files at this level before any subtrees - ordered_subtree = OrderedDict() - if filestats and filestats is not None: - for fs in sorted(filestats, - key=lambda fs: strxfrm(fs.path.casefold())): - ordered_subtree[fs.path] = fs - - if deeper_subtrees: - for key, deeper in sorted(deeper_subtrees, - key=lambda tup: strxfrm( - tup[0].casefold())): - ordered_subtree[key] = _order(deeper) - - return ordered_subtree - - return _order(tree) diff --git a/submit/controllers/ui/new/upload_delete.py b/submit/controllers/ui/new/upload_delete.py deleted file mode 100644 index 9d0abf6..0000000 --- a/submit/controllers/ui/new/upload_delete.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Controllers for file-delete-related requests. -""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv.integration.api import exceptions -from arxiv.submission import save -from arxiv.submission.domain.event import UpdateUploadPackage -from arxiv.submission.domain.uploads import Upload -from arxiv.submission.exceptions import SaveError -from arxiv.submission.services import Filemanager -from arxiv_auth.domain import Session -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest, MethodNotAllowed -from wtforms import BooleanField, HiddenField -from wtforms.validators import DataRequired - -from submit.controllers.ui.util import validate_command, \ - user_and_client_from_session -from submit.util import load_submission -from submit.routes.ui.flow_control import ready_for_next, \ - stay_on_this_stage, return_to_parent_stage -from submit.controllers.ui.util import add_immediate_alert - -logger = logging.getLogger(__name__) - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - -PLEASE_CONTACT_SUPPORT = Markup( - 'If you continue to experience problems, please contact' - ' arXiv support.' -) - - -def delete_all(method: str, params: MultiDict, session: Session, - submission_id: int, token: Optional[str] = None, - **kwargs) -> Response: - """ - Handle a request to delete all files in the workspace. - - Parameters - ---------- - method : str - ``GET`` or ``POST`` - params : :class:`MultiDict` - The query or form data from the request. - session : :class:`Session` - The authenticated session for the request. - submission_id : int - The identifier of the ui-app for which the deletion is being made. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - - Returns - ------- - dict - int - Response data, to render in template. - HTTP status code. This should be ``200`` or ``303``, unless something - goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response, if - applicable. - - """ - rdata = {} - if token is None: - add_immediate_alert(rdata, alerts.FAILURE, 'Missing auth token') - return stay_on_this_stage((rdata, status.OK, {})) - - fm = Filemanager.current_session() - submission, submission_events = load_submission(submission_id) - upload_id = submission.source_content.identifier - submitter, client = user_and_client_from_session(session) - rdata.update({'ui-app': submission, 'submission_id': submission_id}) - - if method == 'GET': - form = DeleteAllFilesForm() - rdata.update({'form': form}) - return stay_on_this_stage((rdata, status.OK, {})) - - elif method == 'POST': - form = DeleteAllFilesForm(params) - rdata.update({'form': form}) - - if not (form.validate() and form.confirmed.data): - return stay_on_this_stage((rdata, status.OK, {})) - - try: - stat = fm.delete_all(upload_id, token) - except exceptions.RequestForbidden as e: - alerts.flash_failure(Markup( - 'There was a problem authorizing your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - logger.error('Encountered RequestForbidden: %s', e) - except exceptions.BadRequest as e: - alerts.flash_warning(Markup( - 'Something odd happened when processing your request.' - f'{PLEASE_CONTACT_SUPPORT}' - )) - logger.error('Encountered BadRequest: %s', e) - except exceptions.RequestFailed as e: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - logger.error('Encountered RequestFailed: %s', e) - - command = UpdateUploadPackage(creator=submitter, client=client, - checksum=stat.checksum, - uncompressed_size=stat.size, - source_format=stat.source_format) - if not validate_command(form, command, submission): - logger.debug('Command validation failed') - return return_to_parent_stage((rdata, status.OK, {})) - - try: - submission, _ = save(command, submission_id=submission_id) - except SaveError: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - - return return_to_parent_stage((rdata, status.OK, {})) - - raise MethodNotAllowed('Method not supported') - - -def delete_file(method: str, params: MultiDict, session: Session, - submission_id: int, token: Optional[str] = None, - **kwargs) -> Response: - """ - Handle a request to delete a file. - - The file will only be deleted if a POST request is made that also contains - the ``confirmed`` parameter. - - The process can be initiated with a GET request that contains the - ``path`` (key) for the file to be deleted. For example, a button on - the upload interface may link to the deletion route with the file path - as a query parameter. This will generate a deletion confirmation form, - which can be POSTed to complete the action. - - Parameters - ---------- - method : str - ``GET`` or ``POST`` - params : :class:`MultiDict` - The query or form data from the request. - session : :class:`Session` - The authenticated session for the request. - submission_id : int - The identifier of the ui-app for which the deletion is being made. - token : str - The original (encrypted) auth token on the request. Used to perform - subrequests to the file management service. - - Returns - ------- - dict - Response data, to render in template. - int - HTTP status code. This should be ``200`` or ``303``, unless something - goes wrong. - dict - Extra headers to add/update on the response. This should include - the `Location` header for use in the 303 redirect response, if - applicable. - - """ - rdata = {} - if token is None: - add_immediate_alert(rdata, alerts.FAILURE, 'Missing auth token') - return stay_on_this_stage((rdata, status.OK, {})) - - fm = Filemanager.current_session() - submission, submission_events = load_submission(submission_id) - upload_id = submission.source_content.identifier - submitter, client = user_and_client_from_session(session) - - rdata = {'ui-app': submission, 'submission_id': submission_id} - - if method == 'GET': - # The only thing that we want to get from the request params on a GET - # request is the file path. This way there is no way for a GET request - # to trigger actual deletion. The user must explicitly indicate via - # a valid POST that the file should in fact be deleted. - params = MultiDict({'file_path': params['path']}) - - form = DeleteFileForm(params) - rdata.update({'form': form}) - - if method == 'POST': - if not (form.validate() and form.confirmed.data): - logger.debug('Invalid form data') - return stay_on_this_stage((rdata, status.OK, {})) - - stat: Optional[Upload] = None - try: - file_path = form.file_path.data - stat = fm.delete_file(upload_id, file_path, token) - alerts.flash_success( - f'File {form.file_path.data} was deleted' - ' successfully', title='Deleted file successfully', - safe=True - ) - except (exceptions.RequestForbidden, exceptions.BadRequest, exceptions.RequestFailed): - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - - if stat is not None: - command = UpdateUploadPackage(creator=submitter, - checksum=stat.checksum, - uncompressed_size=stat.size, - source_format=stat.source_format) - if not validate_command(form, command, submission): - logger.debug('Command validation failed') - return stay_on_this_stage((rdata, status.OK, {})) - try: - submission, _ = save(command, submission_id=submission_id) - except SaveError: - alerts.flash_failure(Markup( - 'There was a problem carrying out your request. Please try' - f' again. {PLEASE_CONTACT_SUPPORT}' - )) - return return_to_parent_stage(({}, status.OK, {})) - return stay_on_this_stage((rdata, status.OK, {})) - - -class DeleteFileForm(csrf.CSRFForm): - """Form for deleting individual files.""" - - file_path = HiddenField('File', validators=[DataRequired()]) - confirmed = BooleanField('Confirmed', validators=[DataRequired()]) - - -class DeleteAllFilesForm(csrf.CSRFForm): - """Form for deleting all files in the workspace.""" - - confirmed = BooleanField('Confirmed', validators=[DataRequired()]) diff --git a/submit/controllers/ui/new/verify_user.py b/submit/controllers/ui/new/verify_user.py deleted file mode 100644 index 95913ab..0000000 --- a/submit/controllers/ui/new/verify_user.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Controller for verify_user action. - -Creates an event of type `core.events.event.ConfirmContactInformation` -""" -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional - -from flask import url_for -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms import BooleanField -from wtforms.validators import InputRequired - -from arxiv.base import logging -from arxiv.forms import csrf -from arxiv_auth.domain import Session -from arxiv.submission import save, SaveError -from arxiv.submission.domain.event import ConfirmContactInformation - -from submit.util import load_submission -from submit.controllers.ui.util import validate_command, \ - user_and_client_from_session -from submit.routes.ui.flow_control import ready_for_next, stay_on_this_stage - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -def verify(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """ - Prompt the user to verify their contact information. - - Generates a `ConfirmContactInformation` event when valid data are POSTed. - """ - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - submitter, client = user_and_client_from_session(session) - - # Will raise NotFound if there is no such ui-app. - submission, _ = load_submission(submission_id) - - # Initialize the form with the current state of the ui-app. - if method == 'GET': - if submission.submitter_contact_verified: - params['verify_user'] = 'true' - - form = VerifyUserForm(params) - response_data = { - 'submission_id': submission_id, - 'form': form, - 'ui-app': submission, - 'submitter': submitter, - 'user': session.user, # We want the most up-to-date representation. - } - - if method == 'POST' and form.validate() and form.verify_user.data: - # Now that we have a ui-app, we can verify the user's contact - # information. There is no need to do this more than once. - if submission.submitter_contact_verified: - return ready_for_next((response_data, status.OK,{})) - else: - cmd = ConfirmContactInformation(creator=submitter, client=client) - if validate_command(form, cmd, submission, 'verify_user'): - try: - submission, _ = save(cmd, submission_id=submission_id) - response_data['ui-app'] = submission - return ready_for_next((response_data, status.OK, {})) - except SaveError as ex: - raise InternalServerError(response_data) from ex - - return stay_on_this_stage((response_data, status.OK, {})) - - -class VerifyUserForm(csrf.CSRFForm): - """Generates form with single checkbox to confirm user information.""" - - verify_user = BooleanField( - 'By checking this box, I verify that my user information is correct.', - [InputRequired('Please confirm your user information')], - ) diff --git a/submit/controllers/ui/tests/__init__.py b/submit/controllers/ui/tests/__init__.py deleted file mode 100644 index 7f0ba98..0000000 --- a/submit/controllers/ui/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for :mod:`submit.controllers`.""" diff --git a/submit/controllers/ui/tests/test_jref.py b/submit/controllers/ui/tests/test_jref.py deleted file mode 100644 index a8ea4fd..0000000 --- a/submit/controllers/ui/tests/test_jref.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tests for :mod:`submit.controllers.jref`.""" - -from unittest import TestCase, mock -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound -from wtforms import Form -from http import HTTPStatus as status -import arxiv.submission as events -from submit.controllers.ui import jref - -from pytz import timezone -from datetime import timedelta, datetime -from arxiv_auth import auth, domain - - -def mock_save(*events, submission_id=None): - for event in events: - event.submission_id = submission_id - return mock.MagicMock(submission_id=submission_id), events - - -class TestJREFSubmission(TestCase): - """Test behavior of :func:`.jref` controller.""" - - def setUp(self): - """Create an authenticated session.""" - # Specify the validity period for the session. - start = datetime.now(tz=timezone('US/Eastern')) - end = start + timedelta(seconds=36000) - self.session = domain.Session( - session_id='123-session-abc', - start_time=start, end_time=end, - user=domain.User( - user_id='235678', - email='foo@foo.com', - username='foouser', - name=domain.UserFullName(forename="Jane",surname= "Bloggs",suffix= "III"), - profile=domain.UserProfile( - affiliation="FSU", - rank=3, - country="de", - default_category=domain.Category('astro-ph.GA'), - submission_groups=['grp_physics'] - ) - ), - authorizations=domain.Authorizations( - scopes=[auth.scopes.CREATE_SUBMISSION, - auth.scopes.EDIT_SUBMISSION, - auth.scopes.VIEW_SUBMISSION], - endorsements=[domain.Category('astro-ph.CO'), - domain.Category('astro-ph.GA')] - ) - ) - - @mock.patch(f'{jref.__name__}.alerts') - @mock.patch(f'{jref.__name__}.url_for') - @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_GET_with_unannounced(self, mock_load, mock_url_for, mock_alerts): - """GET request for an unannounced ui-app.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, version=1) - mock_load.return_value = (before, []) - mock_url_for.return_value = "/url/for/ui-app/status" - data, code, headers = jref.jref('GET', MultiDict(), self.session, - submission_id) - self.assertEqual(code, status.SEE_OTHER, "Returns See Other") - self.assertIn('Location', headers, "Returns Location header") - self.assertTrue( - mock_url_for.called_with('ui.submission_status', submission_id=2), - "Gets the URL for the ui-app status page" - ) - self.assertEqual(headers['Location'], "/url/for/ui-app/status", - "Returns the URL for the ui-app status page") - self.assertEqual(mock_alerts.flash_failure.call_count, 1, - "An informative message is shown to the user") - - @mock.patch(f'{jref.__name__}.alerts') - @mock.patch(f'{jref.__name__}.url_for') - @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_POST_with_unannounced(self, mock_load, mock_url_for, mock_alerts): - """POST request for an unannounced ui-app.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, - is_announced=False, - arxiv_id=None, version=1) - mock_load.return_value = (before, []) - mock_url_for.return_value = "/url/for/ui-app/status" - params = MultiDict({'doi': '10.1000/182'}) # Valid. - data, code, headers = jref.jref('POST', params, self.session, - submission_id) - self.assertEqual(code, status.SEE_OTHER, "Returns See Other") - self.assertIn('Location', headers, "Returns Location header") - self.assertTrue( - mock_url_for.called_with('ui.submission_status', submission_id=2), - "Gets the URL for the ui-app status page" - ) - self.assertEqual(headers['Location'], "/url/for/ui-app/status", - "Returns the URL for the ui-app status page") - self.assertEqual(mock_alerts.flash_failure.call_count, 1, - "An informative message is shown to the user") - - @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - def test_GET_with_announced(self, mock_load): - """GET request for a announced ui-app.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, is_announced=True, - arxiv_id='2002.01234', version=1) - mock_load.return_value = (before, []) - params = MultiDict() - data, code, _ = jref.jref('GET', params, self.session, submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - self.assertIn('form', data, "Returns form in response data") - - @mock.patch(f'{jref.__name__}.alerts') - @mock.patch(f'{jref.__name__}.url_for') - @mock.patch(f'{jref.__name__}.JREFForm.Meta.csrf', False) - @mock.patch('arxiv.submission.load') - @mock.patch(f'{jref.__name__}.save', mock_save) - def test_POST_with_announced(self, mock_load, mock_url_for, mock_alerts): - """POST request for a announced ui-app.""" - submission_id = 2 - before = mock.MagicMock(submission_id=submission_id, is_announced=True, - arxiv_id='2002.01234', version=1) - mock_load.return_value = (before, []) - mock_url_for.return_value = "/url/for/ui-app/status" - params = MultiDict({'doi': '10.1000/182'}) - _, code, _ = jref.jref('POST', params, self.session, submission_id) - self.assertEqual(code, status.OK, "Returns 200 OK") - - params['confirmed'] = True - data, code, headers = jref.jref('POST', params, self.session, - submission_id) - self.assertEqual(code, status.SEE_OTHER, "Returns See Other") - self.assertIn('Location', headers, "Returns Location header") - self.assertTrue( - mock_url_for.called_with('ui.submission_status', submission_id=2), - "Gets the URL for the ui-app status page" - ) - self.assertEqual(headers['Location'], "/url/for/ui-app/status", - "Returns the URL for the ui-app status page") - self.assertEqual(mock_alerts.flash_success.call_count, 1, - "An informative message is shown to the user") diff --git a/submit/controllers/ui/util.py b/submit/controllers/ui/util.py deleted file mode 100644 index 38866a4..0000000 --- a/submit/controllers/ui/util.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Helpers for controllers.""" - -from typing import Callable, Any, Dict, Tuple, Optional, List, Union -from http import HTTPStatus as status - -from arxiv_auth.domain import Session -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from flask import url_for, Markup - -from wtforms.widgets import ListWidget, CheckboxInput, Select, \ - html_params -from wtforms import StringField, PasswordField, SelectField, \ - SelectMultipleField, Form, validators, Field -from wtforms.fields.core import UnboundField - -from arxiv.forms import csrf -from http import HTTPStatus as status -from arxiv import taxonomy -from arxiv.submission import InvalidEvent, User, Client, Event, Submission - - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -class OptGroupSelectWidget(Select): - """Select widget with optgroups.""" - - def __call__(self, field: SelectField, **kwargs: Any) -> Markup: - """Render the `select` element with `optgroup`s.""" - kwargs.setdefault('id', field.id) - if self.multiple: - kwargs['multiple'] = True - html = [f'') - return Markup(''.join(html)) - - -class OptGroupSelectField(SelectField): - """A select field with optgroups.""" - - widget = OptGroupSelectWidget() - - def pre_validate(self, form: Form) -> None: - """Don't forget to validate also values from embedded lists.""" - for group_label, items in self.choices: - for value, label in items: - if value == self.data: - return - raise ValueError(self.gettext('Not a valid choice')) - - def _value(self) -> str: - data: str = self.data - return data - - -class OptGroupSelectMultipleField(SelectMultipleField): - """A multiple select field with optgroups.""" - - widget = OptGroupSelectWidget(multiple=True) - - # def pre_validate(self, form: Form) -> None: - # """Don't forget to validate also values from embedded lists.""" - # for group_label, items in self.choices: - # for value, label in items: - # if value == self.data: - # return - # raise ValueError(self.gettext('Not a valid choice')) - - def _value(self) -> List[str]: - data: List[str] = self.data - return data - - -def validate_command(form: Form, event: Event, - submission: Optional[Submission] = None, - field: str = 'events', - message: Optional[str] = None) -> bool: - """ - Validate an uncommitted command and apply the result to form validation. - - Parameters - ---------- - form : :class:`.Form` - command : :class:`.Event` - Command/event to validate. - submission : :class:`.Submission` - The ui-app to which the command applies. - field : str - Name of the field on the form to update with error messages if - validation fails. Default is `events`, accessible at - ``form.errors['events']``. - message : str or None - If provided, the error message to add to the form. If ``None`` - (default) the :class:`.InvalidEvent` message will be used. - - Returns - ------- - bool - - """ - try: - event.validate(submission) - except InvalidEvent as e: - form.errors - # This use of _errors causes a problem in WTForms 2.3.3 - # This fix might be of interest: https://github.com/wtforms/wtforms/pull/584 - if field not in form._errors: - form._errors[field] = [] - if message is None: - message = e.message - form._errors[field].append(message) - - if hasattr(form, field): - field_obj = getattr(form, field) - if not field_obj.errors: - field_obj.errors = [] - field_obj.errors.append(message) - return False - return True - - -class FieldMixin: - """Provide a convenience classmethod for field names.""" - - @classmethod - def fields(cls): - """Convenience accessor for form field names.""" - return [key for key in dir(cls) - if isinstance(getattr(cls, key), UnboundField)] - - -# TODO: currently this does nothing with the client. We will need to add that -# bit once we have a plan for handling client information in this interface. -def user_and_client_from_session(session: Session) \ - -> Tuple[User, Optional[Client]]: - """ - Get ui-app user/client representations from a :class:`.Session`. - - When we're building ui-app-related events, we frequently need a - ui-app-friendly representation of the user or client responsible for - those events. This function generates those event-domain representations - from a :class:`arxiv_auth.domain.Submission` object. - """ - user = User( - session.user.user_id, - email=session.user.email, - forename=getattr(session.user.name, 'forename', None), - surname=getattr(session.user.name, 'surname', None), - suffix=getattr(session.user.name, 'suffix', None), - endorsements=session.authorizations.endorsements - ) - return user, None - - -def add_immediate_alert(context: dict, severity: str, - message: Union[str, dict], title: Optional[str] = None, - dismissable: bool = True, safe: bool = False) -> None: - """Add an alert for immediate display.""" - if safe and isinstance(message, str): - message = Markup(message) - data = {'message': message, 'title': title, 'dismissable': dismissable} - - if 'immediate_alerts' not in context: - context['immediate_alerts'] = [] - context['immediate_alerts'].append((severity, data)) diff --git a/submit/controllers/ui/withdraw.py b/submit/controllers/ui/withdraw.py deleted file mode 100644 index 0c35a51..0000000 --- a/submit/controllers/ui/withdraw.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Controller for withdrawal requests.""" - -from http import HTTPStatus as status -from typing import Tuple, Dict, Any, Optional - -from arxiv_auth.domain import Session -from flask import url_for, Markup -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import InternalServerError, NotFound, BadRequest -from wtforms.fields import StringField, TextAreaField, Field, BooleanField -from wtforms.validators import InputRequired, ValidationError, optional, \ - DataRequired - -from arxiv.base import logging, alerts -from arxiv.forms import csrf -from arxiv.submission import save, SaveError -from arxiv.submission.domain.event import RequestWithdrawal - -from ...util import load_submission -from .util import FieldMixin, user_and_client_from_session, validate_command - -logger = logging.getLogger(__name__) # pylint: disable=C0103 - -Response = Tuple[Dict[str, Any], int, Dict[str, Any]] # pylint: disable=C0103 - - -class WithdrawalForm(csrf.CSRFForm, FieldMixin): - """Submit a withdrawal request.""" - - withdrawal_reason = TextAreaField( - 'Reason for withdrawal', - validators=[DataRequired()], - description=f'Limit {RequestWithdrawal.MAX_LENGTH} characters' - ) - confirmed = BooleanField('Confirmed', - false_values=('false', False, 0, '0', '')) - - -def request_withdrawal(method: str, params: MultiDict, session: Session, - submission_id: int, **kwargs) -> Response: - """Request withdrawal of a paper.""" - submitter, client = user_and_client_from_session(session) - logger.debug(f'method: {method}, ui-app: {submission_id}. {params}') - - # Will raise NotFound if there is no such ui-app. - submission, _ = load_submission(submission_id) - - # The ui-app must be announced for this to be a withdrawal request. - if not submission.is_announced: - alerts.flash_failure(Markup( - "Submission must first be announced. See " - "the arXiv help pages" - " for details." - )) - loc = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': loc} - - # The form should be prepopulated based on the current state of the - # ui-app. - if method == 'GET': - params = MultiDict({}) - - params.setdefault("confirmed", False) - form = WithdrawalForm(params) - response_data = { - 'submission_id': submission_id, - 'ui-app': submission, - 'form': form, - } - - cmd = RequestWithdrawal(reason=form.withdrawal_reason.data, - creator=submitter, client=client) - if method == 'POST' and form.validate() \ - and form.confirmed.data \ - and validate_command(form, cmd, submission, 'withdrawal_reason'): - try: - # Save the events created during form validation. - submission, _ = save(cmd, submission_id=submission_id) - # Success! Send user back to the ui-app page. - alerts.flash_success("Withdrawal request submitted.") - status_url = url_for('ui.create_submission') - return {}, status.SEE_OTHER, {'Location': status_url} - except SaveError as ex: - raise InternalServerError(response_data) from ex - else: - response_data['require_confirmation'] = True - - return response_data, status.OK, {} diff --git a/submit/db.sqlite b/submit/db.sqlite deleted file mode 100644 index 17b518c086fe0fb3f2dbaab6080dc882d43ecd7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 327680 zcmeI53wRsXb>{(Mh!+XMFBz68N+T&U2}=M8K19imY(pSK(jrI-fMh97f((HnITkPj z&kQKib+(0UCrz3r?juc_rfHJ4o3z<>(=^+Ce!ed(XZ1oZmh7Jp>Dv79?HdR~5A;==_A|Hjmfqd4%UZ z9?u#2f0_Qbex@nFSr7ED*Aea(JmX0o`e-kua?!tLGP&qKM*lwgx2=b6nGg>GAOHd& z00JNY0w4eaAOHd&00JQJFCuXEValFJ(Z{_b^l|V&gg(RJ|L2K*CVKzC1Cd{eO!xnC zztrE`SEES$fB*=900@8p2!H?xfWZDDP!0Q)MkwN4*Y)}XqoZY26m)4rEGxB|B5O%S zT^p@8R;p6DqSORQ9(_twmC;mkY&2s^@|l(>zif!{Glm>LBbKXzN@XcBKa$Ce@suYX zY#s>t6(OkYRvpzP%9^OT>)m^GBZY>f3oBLQPLID_;MbH5QI%_=tn*^6URAb4Q9H#h z{CIIwpa?O}tpxq$+U^u=wN|!9Cp+1n)^$}{X&4rp!pw61{578BYpgA3#yo|O4mi+ZAzk$HmvbCgS`Qzd2;X7%H=)Pyr{^` z_e8ZU3DsTnSjl&|$Pc|e~tne#aBw|*Ot|Ujjst6kzWz1GmHGAg4__)EuN~K3=MnzkzB~*rB)P~G0Z?= zg+>C~AYQ1~t6SwTjpyNk5l{4=qQ4RSuITHc!vkL&_)i1hJ@9&p#19C700@8p2!H?x zfB*=900@Az1hOBIv=PT@ttoJ47V(M|zKKp>M!x2N*6BXZ>PqfVHkZ%Kqf`&8d2`hLFeqkTWr_wK&8^?gg9-nZWO+P>w!nZ7f9 zr~3H5yZVCRe+qvg{JHS|2>)jIm%<+pe>nW!@blqk!<*r1_zmG=crN_v@L2eG__okL zhyE(`>Ci8Sek}Cv(9KXSbTyO>jfD<{g2BHF{!#GPgFh8~fADR=&EQI~7@QA26dVg4 z3*Hs<2fh;ceBiTz{~GwEz{dhV6!@ONw+5aHR0FRMECT05_$etH&^Bp&% zbTuJXJwJV?FK$H$dZH$l*F8Tq;5%Z*DXY5YKiI|8bhVn$%97{f?I=-8tP9nu=O^uw z=^em?EPFl{@*OZr@%%)@H`HUR@X>zXU=O{`rzeYZo_}vYEEPRJZa?G}JU`Zc$a?;r z{g7Gme8hf8FMEE}C|jv+BqUk){0Mt`vLVQNqQ36=ueG@A**ssZjL%+xAmF z=lS9G({kSPLyo7M=Lg$QxeJ~Twx4DfJs)U4Wiy`lx1Tc0o*!sEtw>6;xa4_X`>Bxe ze1H4tQqJ?<_S16K^L_0nV)CB$(_+T+z3r#mqUYW1C(`9z?WfFn&-b*S&gVSu4Etg| zPbNqg&v&TNiJtoUmL`?8q*fQoB8&YF<{x&fQf6__JLBp~ zoyEPwncFV&?M_LhPVa(ho^N-?v3D>j_uKCBowRF_WGzt2> z8(l(W)-F%?`-Z%ALDv(SQvIer-=J4+l&cAS{V6*vZf+UjDs_p%{H7gVF5Ixgnc0nY zc&1^8(@Q!F8)JaJt{GuLUr!c_YCF9AWIH@tx5N3O(hjF(JA5f!v%|~zsvTa+TyKZd zPuStbqSOwj*X?j_d959uUA4mtOQIdVSghFL`C{1)&n>Ok;n||l4(H#{4lkD4;oR%( z@P+j2?C?zCwe9fi<90Z6zQu!mG{DQsF@>H1~iRTAV*?g|cU?P% zXRi(h@ARE?#2O2Pd++w8oN?wF;UF83+6B-u;U1S@YYtu$v=|$wY(e8y{kvV2TWg8~ z?pSkKahFS_*2>~e=C@XnceEa8h^ESJXKr4maCPlA=7;6hcpNok)&+%40t4(}McASW z1UbT_%)z)n>O1Zg=ocT@@R}^-`&`fSxv=YbE*o+^&!&T}=j?L8^_*Gqvx=?qdzq%B z5G|%SBfd)AL{w@%BRBO`sDAI-yY6{l72z1@`;@e^3A52LwO>1V8`;KmY_l00ck)1V8`;Kwuvb==FKM zJ|Fw+q2K?f@6BX3-L?-5g9#u20w4eaAOHd&00JNY0w4eaAOHeg39$Qr?EkysK>`SX z00@8p2!H?xfB*=900@8p23Dy|87>dg3=N7ibBR85wZTCk^bZ}?{F^jLB{bvBuro=B!9-A|hxqXH9-}e9ZybE z1JRNw~|4mIyCdVhIld18kz9IWe6aPo_?vaRk@6 zgPw!26Q%(!ry@R8arH4t)WnP+SCU#;5@kIpmNi|_$c8bOUoAZU6W;NuspKTpFnxxA zO}L*VA5}C&&3jNV%r`|Jq(!D@UBM5RwY3@|En*knofmZP#e?$HyE$8r%yY*OZfO7eRzC|+MODG z{7iCs+V$`TFI6x|En?Vm;)J6`(p#i;x>BJQ*K*>9np~}Jl?9#piaI_<#2l@D`S~CF zt?ARr>9YiDJUKPy472zD!vp`|i9S8>57CwAeDpV>-yQv2bToQb^vC!8KnDXs00ck) z1V8`;KmY_l00ck)1ol6HdwplUosX`?+S~b@=pNscx6{#1fMw};9OIAq zCcGU^QF>X9j)x!IA>TP~w*w2Gp+Sd3hMqy+xlp&$fk^Z#o&(u_QEf#dt2YNYcrwqH1&qO;uMEI^veKQmSjxfoDB;24W9C?0s|8 z(R3Zk=vu;Eotm>l0Y<~_jJGjPjK}$z?1l8@g(5#Z>}q#2S4pT=ZSj;tT6T-ql!{h* ziFqt>k=+S&tYphwiQ!;cNms0z_(Cqdus|){wqnwq#nqlWNf`c_nagG_@*|cy%;Nb) zeq?xS_!K`Z567KCJM8Q#X%*M9&Mg*>H!HUXVkb^`pXqbBz?!Nw>RPMSTj4u8HFPV< zXj>!b>~g8ojy7_mXvw-e;?z`EaW;+Ur7ap*?53IQD(u2+DMg)U`VN2W;)&fmPs<#u zzqFq2Y>q{Z(d}kzJD*ZTTy-E~kGFND#8M4Vf?8guyOfeqylv1fw03Y}1BuH_SCM4v zx!e4)ODFeWD^tp4E{nLm`B)?no0#z4yzH>pRY%KeSJXf!%Uu#K>m6hBE~Z=Uw--zu ziAa@Gyj2K+$qx8q*CuucrJJ-%{kTb3oE|w=0kx%EFSkhJMBQF)6MF2x?5`@ zvYvvS&{&BE66?XSR&W`p-B8quGtr(w$IKu1xVvLnw4In^c|y0@w2-jZGi>EW<8ZBR zue0olE^|4b&n^~AZ1GhnrgO_K9bJv!Fvyt7E-%hsx}4=lEJLU@9t`?pOYz-X!!E{b z1?xFA+OTcvVUpc`SbVP~A*)e@Z~*s|0pfYG+6dZ)9w zx+E zKI4qxT&-p9Ae;h5-?1X>;dHyNyBdt{JF%o_0k&<@wJIPQ8O|SjEY*$1 zP7ACt)+&P8+1tG4HAaq`H3tduTzZK?G*@EQQ%@%XR-$434&z*IRuxvn_A+;Gw$ld1 zvGp=>Y2uR&Q7+rSc0gi!uuk1`>rQRyZ^5yieY7JDvR!E;@($0Pp4WPU|0Vc*urKgE zfqVS#_a}Ql+B+4!84VA-V<7Ci8Tl=r&wIf0X%c%$Kbs?g*twJ5<^f|bC2g9!DQg!; zD}70~cD8opNwXQ|7iY3p`D5L39pjf4t&Ogoms-gvGJbCO)@wC8m&L}8xc`~!_Zh3x zH%A;^V<)#<&wh+|@*6wVa2)H&IjcB_xgApoZHwEZlY4M747B?nbF*%ew2NJL&NdZe zM{gYhv}ZMU`E^yQ2{iW6to4Lg)>|XIT^}tgnLBnm9x$dkonD|Sxi@^f4BPE2?hUY)7Bl5yhK080V$oBOk&QjxiK6@ccGifGU;9FxQJFd|95%v$Y2lb;jo(RO! zac^_T?WX1`L@UvWi?P~mwYwEP(#21$bUT~VQ>{eu3aixkGh@e%A?i(o4rj9j-F4pA z$<-`T!_&+M=4jR_%`Q6uONYJ4S|#rs1Z_`pJ5pX=h_;H6Wa@*mP<15mx2)UB)VXpi zHC{hNBRbU{>0~%91@#hh4@b>McB|RWu%lKxCB898os(*`=MYEXr6=f=PHZ`jBl_@e zbvm-_sMV1cfBN2AaTe-yn7QF<1* zt=i79vtBzX-t4(25Syo3Pjsx6xwTl4^pY`vI(QH6TD3dd&idWy@u`@f>Cojk)={gq z^fk1(=PlX01F_@Bz2Di|OgrQp!I-IN>y+G)z&0Z0d|;0$&iT1b+#brNS_jv*qi#>o z8mp_+$z7wRnMYd}RI%hJbH^~2+BT9J8QN9dO5Q#H?zTi|sbU>rwr&UP;rxtS3qz{3 zqBP_(?YeDFw)nP9lyigF-rRMQc67a4mTbyO^^)9Z-z1nT6Ejg2YqYd3xvn5~17UPO zYnZ#G28MLIFT4bKZHrzpukA4dJ2x-UMpRmr_AuGGMv$E~Z1X8Y+MK0!3U6ymLwH4y zX|Ua!>At`?_}}5JoeE#qYt=oj|gtyVew~F~^xK(o)oM1VH<%hhEYi%O(8at8Tp`w3dt3 zuE%;FZuT78W%%Dl-7$Ec*lqaVmTkxIzb!q!b##}Nm6Mx8fmiLOfHTjI+MS8<4StuE zhNA~KYCgVO&5kTPDt4sBpFXn7O2akQIch$(Tg|RaJL-0&#*O#?qrdHm{!{eJ(f=L& zU(x>=eKGn+(cg=HiX!m?0w4eaAOHd&00JNY0w4eaAOHd&@Ny);`GVds`v|d*Ao~cg z4?p|pWgi^-@Uf2`pD!3{?f>8Dq22!v(C+`8(a%JGIr`JlKa2iW^haKf20|wg009sH z0T2KI5C8!X009sH0T9?X1VX-;*Z8K*Fbf&qh8c?Zj(F{_tqihwBbim_5^*gkns(Ixc}cbUIRlw00ck)1V8`;KmY_l z00ck)1io{~z*1esdGv3hAELhl@H5{y*kCCLfB*=900@8p2!H?xfB*=900_J+2n2ncm;FJ_ zFbl9hd>LW^_NOU>EWrMHWPk!JdGR3$eco z*t-9JlPCJ6=wHwoz{jIM9DP3ers#i*{(kg5FAK||1PFit2!H?xfB*=900@8p2!H?x z>>C2?#r!Lxx+WMe{6E@|*TfKeZ~rep^6t<5%yYp3-%<89e@(1Pa`}3I{VKp5ePgyN zm9KC5*;A$}i1c?jWUZH#^q3@SBG>O5^1ep6Ayl5aF8P?y`Com3zBrh^yR|3m8w{l@ z70RyR{r`RAL@)#dKmY_l00ck)1V8`;KmY_l;2Vbk`(D4V;`{&b{r}%ME&@wI00ck) z1V8`;KmY_l00ck)1oi=euX6uC=ZXG$RF2L?ACKOozy9~r^!xwsjDBGs@PG**00JNY z0w4eaAOHd&00JNY0wC};C2)r??oFK@OOB_`CR5WB$<$=H6*ZYmO;FU?@#OSm=uY2B zZ)##PIX*d^OpQ+^$0t%jCN@4vImb>XCn@9f>A>y2Vee@sk)raZl2eoZsPDLUaw<77 zog7c5PM_&DGEGe-C+Tte4CS5RjIzg1Q_b`|G0t-OSV7}cR5lenekM6R-4pel2u+_( zPM@Vb+^bh!p3LF z_zW7K0prteeD)fjobl;1K6^NyH`Igs|2;B>7zls>2!H?xfB*=900@8p2!H?xynG07 zz6ksNzlb-)K7#Bcz&`x!qnCYf?8C=CdVIb}D8h8V?d4+tGywq+009sH0T2KI5C8!X z009sH0TB4w6JYoM*#Cd+xuQf6009sH0T2KI5C8!X009sH0T6gO5y1Zc<XHKwFYzn0T2KI5C8!X009sH0T2KI5CDO%4*~Z3{~>SA6MTE%OM!3d{YdYt zynn~N=*@)=dtUJHtWIx&ed1>i1e(Xk0(GAED7pWX`@sYba72lB~dGh zaz#-!u_nrTsiM>bN!~6pnaO9<#VkL+IFr4~AKP0Y$M~g1{@C`Sj`1Tq>x(~EO9oIzN&+9p`7V7t)s(iu~{} zzqnN77cVa?oZ`bFt+DciSk_DBs-S6m$xsnjC0Wv0@$-wt>})pQE_%#ewou-anAsd# zC0VGobsnFZiZcaGVkMzkZL8-nr{b>D5*Mj0UrGjt2iVn2j2jx%g}SI}C0((+g)ijN z3kzg@SId|4^SN~X8hv;}q{=Z4`ZBDW9F6U1V~WSe(yiFJ$xC#Z0zfHLqEM zMh;{KOH#!sATyWET(sTFfHc3zj|^`OpW=sQ_E8y*cam)J=_2b?tAodO&~T>|Q+8); zJRE;`)E_G(ydI-ZYEM=rT`UO=T``___R!S!4lvQ=5`kFeq_;V2b!%OyOKM4$%Ga3- zX?AE^e9G)Pmlx+RUAB5Ob?9wkc6Qc@x25qTRxR;oa;E~ZlPA4zx@7d*ZGzh#$6UR3 zTOvOa4zXgXQSCnE?4wqdwquTu#a*4ap^2(9v)$LXbx1=_6Q8te=_a^Z71l}{Le-&A zH&Ioo-g+dcD^;nywYO^Ps#FuyTMN7Tn2B#kr!vX7I#r84OVRidOCTKIJQ;|cBdsDl_q#+!HFABbI|s?T++nnsSAr0dkm zM!(&rMDmtOY|GzGoo!Av@Bk35+SQCK&}d`;fB(>Ra{p#B|1RGOHCGS=ekv(IsQP zVDZ-UOWoC&YHuiNWt%*6KS^FyI&}?}N7C5XxUQ(31YBc)u%?P)se4CbrEN&!4Y57O zIJ&f?Sbtv!5#0Ky-!cJOty2m=VLRutct8p@iVl*D$eJ!tiVRy=oGFv zO&9crc1r}BPJ`%H$t}&wtTi;fR9BU zhFTU&n)Fn6Xf2UdMXd?C?GCBdvck;IhFZtMtCdEJ9py$z7dM&Kl}5RHqt%7-b=GXQ zuhFupy)1FBjmVjl4I1@!(Rf|ZN_DBK&}vxF1&bBqLz!<#`g%nbZpc=AyP|CqjV|dl zD-^R=i!5u)gEjlPtkjGV)s7$|H@93SuxzSWS5&#6ZQ~AGaFy1TYS+QQHM_P)Oe$u_9P1bg zUGuCt+*-+OL1=|pF>G};lU>Nt-YSzWWYRNP7IS%-bv`SGQFAUvHFF5yo@i z)!1S?^4sGPQ^2v%?zkrSYBV&R##Oip!c1Zu*jQ-*aGM*BWfdKCR z_W%hZAOHd&00JNY0w4eaAOHd&00JQJ3MYX3|5tbeLVXYb0T2KI5C8!X009sH0T2KI z5ZD6&?Em)w2_hf>0w4eaAOHd&00JNY0w4eaAn*z&fcO7j;SC7&K>!3m00ck)1V8`; zKmY_l00cl_4+L=kzXwPV0Ra#I0T2KI5C8!X009sH0T2LzS2zLe|6kz^2=ze#1V8`; zKmY_l00ck)1V8`;Kwu99u>ao!B#3|j2!H?xfB*=900@8p2!H?xfWRx90Q>#_f!;s# z_#3@{7=7cwmj_Cb--$ff|MUG5;qM5a54|t=w}Ei)GWTxZpLwb?vRC}SckzP~X^{Oc4MopCUPHLUrgIbRK z-L-NE#h9Ed&qu=h=m>B34?+-^jvEa>8zqHc9fm@%~~rq3^AcT%tmAR6INy9vlZ z;#X}p)M<} zR1sIHGV3uOjyLarC=k2$ptqTG`=0FzmTbw6u{wqwVTokA_~8ygXIV~f+#yK1+Hq1P z-aL9P5G#02plsnqgLy9_J({GK(na@*!wLd02==B&HYEM=rT`UO=T```` z0cVFkrBoM1)6X1vFc5q2LGN>y9o=w8{w}#6>C^>xWHvgW9qZ_Rj&9)UUY4z{uITKm zFQNar`j$h9U3!*7s#CwRl3A~^!qipctOvRK)Yqs7`5y?xvNU?1a64stNYhGHsVvIc zjzLs=xQicl$m?`KM=}{{79M}&SsJiOkAvOy(6{P4m3U|mO0bOGHDIan=7BS}oLyDD zq)X-NV#jIh+#d9B7SLTEr&PQdnhwNfiA%gY7wv|kRys|suik@BR_5;dSQ5nO^eq{Y z&z9;!z4Hp>!9D2XETFqiPN{g)Hx-Cwh|$qq80muE(6&$a+5>ygNHel_*QJ#nZ+a#J zu{3Eh)Ljc2T9oTMlsK~oC5)Wibug0Sxc|pi0R%t*1V8`;KmY_l00ck)1V8`;_CEpa z|M!0z!$J@M0T2KI5C8!X009sH0T2KI5WxN)J^%tB00JNY0w4eaAOHd&00JNY0{fo; z_W%38jbR}OfB*=900@8p2!H?xfB*=900?0J4<7&l5C8!X009sH0T2KI5C8!X0D=8a z0Q>*_-^Q>I1V8`;KmY_l00ck)1V8`;KmY{T-~aR61`Z$q0w4eaAOHd&00JNY0w4ea zAOHgUnE>wp_jBvQHV^;-5C8!X009sH0T2KI5C8!XXc55vAD#dLAOHd&00JNY0w4ea zAOHd&00R4;0QUd;zl~ub2!H?xfB*=900@8p2!H?xfB*>K_y6GoAOHd&00JNY0w4ea zAOHd&00JPe{|Vs!fB&~JECc}%009sH0T2KI5C8!X009sH0qpT2Q|G)p+7#4y62!H?xfB*=900@8p2!H?xfB^RY@Bt720T2KI5C8!X z009sH0T2KI5ZM0&u>arxZ43)R00ck)1V8`;KmY_l00ck)1V8}a{|_Gk0T2KI5C8!X z009sH0T2KI5CDPwPXPP>{ols05ClK~1V8`;KmY_l00ck)1V8`;@cuu100ck)1V8`; zKmY_l00ck)1V8`;_CEo3{~rpbJ<%VFdIv5<-roP&@Nn>x!4FXsen0>OKmY_l00cmw zGl9xnAa*w9ZT3oXMckA&OX7wo>m{wRQj;`IQsk0UF`^bT`E0tF<>wb?vRC=eg8b4V zZwT`vt^(rmS%2(Y%yn#x?((=Ri=!b3peu@0AX?$EX z6)2|9FJ!H1!XYD#l;p3Z^O?DHeq?ei&Mz(%`Nhi%3#WKXK;Np1w#fL@RJ@(EA*#&K zcIudsSXY(JExwq&T4bW-szhAckE$r>it2tAEBp*0D$eJ!EK5bu1zW(ygCG{Ov)Me8 zTF%es()nxr#q2eH#4^TE;KEWqJ3qU~Qnumb^Vtj8e0DLDE%3(#^{TYtXfW;AcKYG? zbK}`S?9wIgTb2x{+q0C)f-bHp>Q;%2t}B{Qy|u)nuIAXG#m*v0t#HUHpEZ~X!PQt@ zFk zp3&B@ZH=oCv(<%4O_EDhWsRD+gDoAH?4gu!yg51(h&}$Ww|U7Lo4R{t+YP$P%)17# zV>E(w6)m-kJI2Pe?%vHTwmmT3oXP}ZCDQoX-ZXBkR3&X)taMdm?j`DM6}UI8E$Mjk z%=th}AiZCAYrS1Pw5#H?FF|oviMQ6=B_40a(}CDyr271=Rky~Fu3BGs30hl)-CAKw zGJfe1e@uM1+bq~st5T|KQWp3BotHI;1pyEM0T2KI5C8!X009sH0T2Lz{X~G>|JOW! z?TP+z^arEQL?4f)qv3(y8~EtJ(*q9=+!pzx$S+1d5P3H8hRA5-_Wn=zzrTO8f3ZK_ z@9q0k-v|4i>bu%^rtd)b55hkiepgrvr^83Xk?Ek#~GyWI+ANIe+U-GB@NBzOx&-Z?!_x-(F zy^r@k*n2Pc7u?Tr-@&bL4{!&4|KR(5-!J&y=iBhT*7u0-n9tMmXFb2t^TD3yda6CS zo>M&$?_YU;&-)AB_j=#xEqk+G-Ww#-?4NsU+~CPjP0-hqm(rISf~+@ce7d?OuBgJ@ zRc`QjD{VQytST$QibS^zTgR_+gXdcDOPQs*F4d%`1f8As3vz|eD6+09)k9BkgQKQU zdZ{j|#?ii6vUZQe4Gy1FNSRokH4Y);fEJ3lKbH41IrCUS$ZR_2AJ1!awrD%{|7 zE9PSHVxpyjVaA+NQ|R<{Yp87NGhdvSR~402NVc|QkF41G%q`7H8r@%P8Tps!24q7L zZwv``{@J3b2-RDpD*;w~>Ojw*1Wdc~Wa~*y?H#lJ? z=B&gv4ejvj?DPxi3k|uZ6@>c{^Vz*1$z1Qp}$P_c?Er@B>Jy-39&n!{S6-j1}K@E4` zW87e-Wm6`dsS0#a=4|*D)S0X{bj1dmE}H)206KWt=9XTzyoOg+`DK>3*v>oK&TE-< zxL{*TXGpnPgKjsZ4bkclNAgxo9n2Y$;$~;T>?ZX^ ziQTfKa$M|np*1z}WZ80|yhs&D8`Q@Q+}3!IQM6WPII_SEO@vmNLnQ>cgJT`O$Owv} zrX>s4EO&B_3Xd|!X{9e0q&4Q0G_sT_gCwt=nCAx5tzhXYY_1h+j3?UR87?**s;ni}gwEX~!^I{-7Tr6fJ&lHq~Tp4=U63-O2YAZ^Wx&`Z; zh8HXH?GJIWAts(*$}1}hq0kPWV}?-F@&dKOnkv-j(rJxf5ahK6(bw*JH5WT>q+eJT zRb%Xv%i_Hca@1Jj*he1VVrPxCxg{E$RZ{^96v}kjTNV>%x!7eUusCm; z$#S;{=%Ol%I-gd{>vWl2)*GsL{~0c}WQb=M#ReM#nP_%HsWyx;jYiv+iB!tqG#5)5 zvK=Ol!>3JCa|Kt=wZPsv#l?=Y%(+~yQKdOetVqJ(q*-DvZA20iy011(JvPC`9%ebL zxkQq8SbK1sizN)1*_=RD$s)fX3aZSsO_^nPnpO^vncB|hosN-b{wHn-)#IaFEX~TD z%gi-uf}ChUAk6bJQ51{;Kc3`bIVLn)w3E$NFeuInHPIZ9ZcmuL!6qT0zAlxu!BeJw zvkTVnut2Rs^Efq2;-uM%7ZxtC8L`^-8KdYly@x>aq+k|*_%EitZr7pB;ZLLFCiB^X_!kfJ> zGwTo`3f2sF<_H(NZj_OA3pj?AQ~U**l&Pz>h?pL8^srSsn;ES!g^FfRH2sr;hlflo z>HM6aR*d1w*_Zi4Te8xY41UaEt5u15R$07nkQ=3uMUg8B@}8vDsH+Kj zDl}-$VzJ~D#_Tk9FL!d*A*ZVeu}V&($ZJ(LAgC&v$mlJDY?X!)iq=jZhDLXB_Z@IqU(?lULMuyD7Y+A9RkMcB$-B5wnn0`) zYoeA|Cl}6%nxc^%tx0oHkj*D^ZF2Tb?&P(0u8O23WSM$`qE={qSFuJlqZgP%tu+rF zx`P{{QOvAxJB88PxuF83*6Pgm3^ithUUlK^A)w`;#`1(Wh#b3(J4lX9^3^qmFBvsk zo;(<3%eXQv?`f%(p?{of{6hnVKI?*#sBFm@`j;AzH`lS+P=p&A4Xp@U#6^(LvwtN) zws#O?{oLSVAzE~@lGF5$R6fP$6mk?pZKf^T(=dlH$tC)@7!GpB&$aW+rX6w`Tl&t@yGNTe1dax{Tgsub;`0U+daM9&S|(1 zU89npVl$4lMDemktWYjfV{U2ba#^gJyHm5h+5Z1x?^`|5KaKuk^atn!K#a~s4-9-| z;1dJiKOhY}Ixsrmi+ncnqmeg9ijlEMsQ<{{$Std`hKnN?R}5;P4^uN ze>wa+;g5vh9DX8vK71@32>n&)Q=y*>y(6?5dMGp){BrQq!H)&s6MQ;&EqFS3N8pQr zPX<01cs8&Ym<$XCdixfB*=9z&8Ma;pQ1` z_-sfQwf17LNDusu72!~GnmZmhC5`2vA!o1u_~vOYZl{}zKSRV`_1)c^;*K0Ki=gFR zt0D>t5sQP#@JR%o<7zPCBS4VyWw)tDhLN6WI|<~W<4b!J0r(Pbpj%1dp~ zI)hG^tYubzGsPV^U`R5Nq2?G{$aGxMu{pmbUntrb zXccAdbq1R!jQWfc^SQjFU8kaC+VHb=+tKE$xS?q?KRXMjtqavhODl78MvE)Hd7K-X zvSf10imI>D5scy}hHoBgi{vh}H?B@Sj<)q^pGlco9%%C1&=IqaY{ovRI@&za)-SW{ zSX8%5KHNOa)>Ka>RwN}^Tq@FUA~Z;AW4+7PNZQfnP^%CoP{w)lI%edd zmIX}aQqE`=+GZPWY%Xa}GzYn%hs}DHv&%}I*41p?+p5bxy1B1;FE^AnDxYZH$;GmiPAgc;bEfLW^*%CRp2!qs`m7#Bj*mRV1~#K&NN*^#c3Xk~z`5jf+pT zWfZokH9Lt>lX)U>v>D~%CvCa9QfG2T)2LQi$C?9Nf|P0PVv?Pex~Cc8;sX2kDQ4#e{8>AUJ+9}h`fHv6-b0?SVEbEe{ zQ@PHomgRM+s%UKcWt{W3^gq-Laq**eHrhr|E;&EhN;L%Ni349aflTjJ=T?zG&-5+e)^hw|AATvCZ6BnmJB9?d&hD zO<;#D;Qimk4b6w@bjp*UiOV{$Gmbj#Ej-O&l1?Mh!Kb*vct~!Pt90wLNdM3QuQ>+~ zZL#|%BT3w}lG-;gf&VqUD%ugcg*FRc=W TWcwO0pU%@v-s*DZ2(A5pP)08d diff --git a/submit/factory.py b/submit/factory.py deleted file mode 100644 index 31ff485..0000000 --- a/submit/factory.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Application factory for references service components.""" - -import logging as pylogging -import time -from typing import Any, Optional - -from arxiv_auth.auth.middleware import AuthMiddleware -from typing_extensions import Protocol -from flask import Flask - -from arxiv.base import Base, logging -from arxiv_auth import auth -from arxiv.base.middleware import wrap, request_logs -from arxiv.submission.services import classic, Compiler, Filemanager -from arxiv.submission.domain.uploads import FileErrorLevels -from arxiv.submission import init_app - -from .routes import UI -from . import filters - - -pylogging.getLogger('arxiv.ui-app.services.classic.interpolate') \ - .setLevel(10) -pylogging.getLogger('arxiv.ui-app.domain.event.event').setLevel(10) -logger = logging.getLogger(__name__) - - -def create_ui_web_app(config: Optional[dict]=None) -> Flask: - """Initialize an instance of the search frontend UI web application.""" - app = Flask('submit', static_folder='static', template_folder='templates') - app.url_map.strict_slashes = False - app.config.from_pyfile('config.py') - if config is not None: - app.config.update(config) - Base(app) - auth.Auth(app) - app.register_blueprint(UI) - middleware = [request_logs.ClassicLogsMiddleware, - AuthMiddleware] - wrap(app, middleware) - - # Make sure that we have all of the secrets that we need to run. - if app.config['VAULT_ENABLED']: - app.middlewares['VaultMiddleware'].update_secrets({}) - - for filter_name, filter_func in filters.get_filters(): - app.jinja_env.filters[filter_name] = filter_func - - # Initialize services. - - # Initializes - - # The following stmt initializes - # stream publisher at AWS (DEAD) - # preview PDF service - # legacy DB both normal submissions and a new events table - init_app(app) - - Compiler.init_app(app) - - Filemanager.init_app(app) - - if app.config['WAIT_FOR_SERVICES']: - time.sleep(app.config['WAIT_ON_STARTUP']) - with app.app_context(): - wait_for(Filemanager.current_session(), - timeout=app.config['FILEMANAGER_STATUS_TIMEOUT']) - wait_for(Compiler.current_session(), - timeout=app.config['COMPILER_STATUS_TIMEOUT']) - logger.info('All upstream services are available; ready to start') - - app.jinja_env.globals['FileErrorLevels'] = FileErrorLevels - - return app - - -# This stuff may be worth moving to base; so far it has proven pretty -# ubiquitously helpful, and kind of makes sense in arxiv.integration.service. - -class IAwaitable(Protocol): - """An object that provides an ``is_available`` predicate.""" - - def is_available(self, **kwargs: Any) -> bool: - """Check whether an object (e.g. a service) is available.""" - ... - - -def wait_for(service: IAwaitable, delay: int = 2, **extra: Any) -> None: - """Wait for a service to become available.""" - if hasattr(service, '__name__'): - service_name = service.__name__ # type: ignore - elif hasattr(service, '__class__'): - service_name = service.__class__.__name__ - else: - service_name = str(service) - - logger.info('await %s', service_name) - while not service.is_available(**extra): - logger.info('service %s is not available; try again', service_name) - time.sleep(delay) - logger.info('service %s is available!', service_name) diff --git a/submit/filters/__init__.py b/submit/filters/__init__.py deleted file mode 100644 index 22d79be..0000000 --- a/submit/filters/__init__.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Custom Jinja2 filters.""" - -from typing import List, Tuple, Callable -from datetime import datetime, timedelta -from pytz import UTC -from dataclasses import asdict - -from arxiv import taxonomy -from arxiv.submission.domain.process import ProcessStatus -from arxiv.submission.domain.submission import Compilation -from arxiv.submission.domain.uploads import FileStatus - -from submit.controllers.ui.new.upload import group_files -from submit.util import tidy_filesize - -from .tex_filters import compilation_log_display - -# additions for compilation log markup -import re - - -def timesince(timestamp: datetime, default: str = "just now") -> str: - """Format a :class:`datetime` as a relative duration in plain English.""" - diff = datetime.now(tz=UTC) - timestamp - periods = ( - (diff.days / 365, "year", "years"), - (diff.days / 30, "month", "months"), - (diff.days / 7, "week", "weeks"), - (diff.days, "day", "days"), - (diff.seconds / 3600, "hour", "hours"), - (diff.seconds / 60, "minute", "minutes"), - (diff.seconds, "second", "seconds"), - ) - for period, singular, plural in periods: - if period > 1: - return "%d %s ago" % (period, singular if period == 1 else plural) - return default - - -def duration(delta: timedelta) -> str: - s = "" - for period in ['days', 'hours', 'minutes', 'seconds']: - value = getattr(delta, period, 0) - if value > 0: - s += f"{value} {period}" - if not s: - return "less than a second" - return s - - -def just_updated(status: FileStatus, seconds: int = 2) -> bool: - """ - Filter to determine whether a specific file was just touched. - - Parameters - ---------- - status : :class:`FileStatus` - Represents the state of the uploaded file, as conveyed by the file - management service. - seconds : int - Threshold number of seconds for determining whether a file was just - touched. - - Returns - ------- - bool - - Examples - -------- - - .. code-block:: html - -

    - This item - {% if item|just_updated %}was just updated - {% else %}has been sitting here for a while - {% endif %}. -

    - - """ - now = datetime.now(tz=UTC) - return abs((now - status.modified).seconds) < seconds - - -def get_category_name(category: str) -> str: - """ - Get the display name for a category in the :mod:`base:taxonomy`. - - Parameters - ---------- - category : str - Canonical category ID, e.g. ``astro-ph.HE``. - - Returns - ------- - str - Display name for the category. - - Raises - ------ - KeyError - Raised if the specified category is not found in the active categories. - - """ - return taxonomy.CATEGORIES_ACTIVE[category]['name'] - - -def process_status_display(status: ProcessStatus.Status) -> str: - if status is ProcessStatus.Status.REQUESTED: - return "in progress" - elif status is ProcessStatus.Status.FAILED: - return "failed" - elif status is ProcessStatus.Status.SUCCEEDED: - return "suceeded" - raise ValueError("Unknown status") - - -def compilation_status_display(status: Compilation.Status) -> str: - if status is Compilation.Status.IN_PROGRESS: - return "in progress" - elif status is Compilation.Status.FAILED: - return "failed" - elif status is Compilation.Status.SUCCEEDED: - return "suceeded" - raise ValueError("Unknown status") - - -def get_filters() -> List[Tuple[str, Callable]]: - """Get the filter functions available in this module.""" - return [ - ('group_files', group_files), - ('timesince', timesince), - ('just_updated', just_updated), - ('get_category_name', get_category_name), - ('process_status_display', process_status_display), - ('compilation_status_display', compilation_status_display), - ('duration', duration), - ('tidy_filesize', tidy_filesize), - ('asdict', asdict), - ('compilation_log_display', compilation_log_display) - ] diff --git a/submit/filters/tests/test_tex_filters.py b/submit/filters/tests/test_tex_filters.py deleted file mode 100644 index 3ea45fa..0000000 --- a/submit/filters/tests/test_tex_filters.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for tex autotex log filters.""" - -from unittest import TestCase -import re - -from submit.filters import compilation_log_display - -class Test_TeX_Autotex_Log_Markup(TestCase): - """ - Test compilation_log_display routine directly. - - In these tests I will pass in strings and compare the marked up - response to what we are expecting. - - """ - - def test_general_markup_filters(self) -> None: - """ - Test basic markup filters. - - These filters do not limit application to specific TeX runs. - - """ - def contains_markup(marked_up_string: str, expected_markup: str) -> bool: - """ - Check whether desired markup is contained in the resulting string. - - Parameters - ---------- - marked_up_string : str - String returned from markup routine. - - expected_markup : str - Highlighed snippet we expect to find in the returned string. - - Returns - ------- - True when we fild the expected markup, False otherwise. - - """ - if re.search(expected_markup, marked_up_string, - re.IGNORECASE | re.MULTILINE): - return True - - return False - - # Dummy arguments - test_id = '1234567' - test_status = 'succeeded' - - # Informational TeX run marker - input_string = ("[verbose]: ~~~~~~~~~~~ Running pdflatex for the " - "second time ~~~~~~~~") - - marked_up = compilation_log_display(input_string, test_id, test_status) - - expected_string = (r'\[verbose]: ~~~~~~~~~~~ ' - r'Running pdflatex for the second time ~~~~~~~~' - r'<\/span>') - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Looking for informational TeX run markup.") - - # Successful event markup - input_string = ("[verbose]: Extracting files from archive: 5.tar") - - marked_up = compilation_log_display(input_string, test_id, test_status) - - expected_string = (r'\[verbose]: Extracting ' - r'files from archive:<\/span> 5.tar') - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Looking for successful event markup.") - - # Citation Warning - input_string = ("LaTeX Warning: Citation `GH' on page 1320 undefined " - "on input line 214.") - - marked_up = compilation_log_display(input_string, test_id, test_status) - - expected_string = (r'LaTeX Warning: ' - r'Citation `GH' on page 1320 undefined<\/span> on' - r' input line 214\.') - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Looking for Citation warning.") - - # Danger - input_string = ("! Emergency stop.") - - marked_up = compilation_log_display(input_string, test_id, test_status) - - expected_string = (r'! Emergency stop<\/span>\.') - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Looking for danger markup.") - - # Fatal - input_string = ("[verbose]: Fatal error \n[verbose]: tex 'main.tex' failed.") - - marked_up = compilation_log_display(input_string, test_id, test_status) - - expected_string = (r'\[verbose]: Fatal<\/span> error') - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Looking for fatal markup.") - - # contains HTML markup - from smileyface.svg - input_string = """ - - - Smileybones - Created with Sketch. - - - - """ - - expected_string = """<?xml version="1.0" encoding="UTF-8" standalone="no"?> - <svg width="174px" height="173px" version="1.1"> - <title>Smileybones</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Smileybones" stroke="none" stroke-width="1"> - </g> - </svg>""" - - expected_string = """<title>Smileybones</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Smileybones" stroke="none" stroke-width="1"> - </g>""" - - marked_up = compilation_log_display(input_string, test_id, test_status) - - found = contains_markup(marked_up, expected_string) - - self.assertTrue(found, "Checking that XML/HTML markup is escaped properly.") \ No newline at end of file diff --git a/submit/filters/tex_filters.py b/submit/filters/tex_filters.py deleted file mode 100644 index 137709a..0000000 --- a/submit/filters/tex_filters.py +++ /dev/null @@ -1,548 +0,0 @@ -"""Filters for highlighting autotex log files.""" - -import re -import html - -TEX = 'tex' -LATEX = 'latex' -PDFLATEX = 'pdflatex' - -ENABLE_TEX = r'(\~+\sRunning tex.*\s\~+)' -ENABLE_LATEX = r'(\~+\sRunning latex.*\s\~+)' -ENABLE_PDFLATEX = r'(\~+\sRunning pdflatex.*\s\~+)' - -DISABLE_HTEX = r'(\~+\sRunning htex.*\s\~+)' -DISABLE_HLATEX = r'(\~+\sRunning hlatex.*\s\~+)' -DISABLE_HPDFLATEX = r'(\~+\sRunning hpdflatex.*\s\~+)' - -RUN_ORDER = ['last', 'first', 'second', 'third', 'fourth'] - -def initialize_error_summary() -> str: - """Initialize the error_summary string with desired markuop.""" - error_summary = '\nSummary of Critical Errors:\n\n
      \n' - return error_summary - -def finalize_error_summary(error_summary: str) -> str: - error_summary = error_summary + "
    \n" - return error_summary - -def compilation_log_display(autotex_log: str, submission_id: int, - compilation_status: str) -> str: - """ - Highlight interesting features in autotex log. - - Parameters - ---------- - autotex_log : str - Complete autotex log containing output from series of TeX runs. - - Returns - ------- - Returns highlighted autotex log. - - """ - # Don't do anything when log not generated - if re.search(r'No log available.', autotex_log): - return autotex_log - - # Create summary information detailing runs and markup key. - - run_summary = ("If you are attempting to compile " - "with a specific engine (PDFLaTeX, LaTeX, \nTeX) please " - "carefully review the appropriate log below.\n\n" - ) - - # Key to highlighting - - key_summary = "" - #( - # "Key: \n" - # "\tSevere warnings/errors.'\n" - # "\tWarnings deemed important'\n" - # "\tGeneral warnings/errors from packages.'\n" - - # "\tWarnings/Errors deemed unimportant. " - # "Example: undefined references in first TeX run.'\n" - # "\tIndicates positive event, does not guarantee overall success\n" - # "\tInformational markup\n" - - # "\n" - # "\tNote: Almost all marked up messages are generated by TeX \n\tengine " - # "or packages. The help or suggested highlights below \n\tmay be add to assist submitter.\n\n" - # "\tReferences to arXiv help pages or other documentation.\n" - # "\tRecommended solution based on " - # "previous experience.\n" - # "\n\n" - # ) - - run_summary = run_summary + ( - f"Summary of TeX runs:\n\n" - ) - - new_log = '' - - last_run_for_engine = {} - - # TODO : THIS LIKELY BECOMES ITS OWN ROUTINE - - # Lets figure out what we have in terms of TeX runs - # - # Pattern is 'Running (engine) for the (run number) time' - # - # ~~~~~~~~~~~ Running hpdflatex for the first time ~~~~~~~~ - # ~~~~~~~~~~~ Running latex for the first time ~~~~~~~~ - run_regex = re.compile(r'\~+\sRunning (.*) for the (.*) time\s\~+', - re.IGNORECASE | re.MULTILINE) - - hits = run_regex.findall(autotex_log) - - enable_markup = [] - disable_markup = [] - - success_last_engine = '' - success_last_run = '' - - for run in hits: - (engine, run) = run - - run_summary = run_summary + f"\tRunning {engine} for {run} time." + '\n' - - # Keep track of finaly run in the event compilation succeeded - success_last_engine = engine - success_last_run = run - - last_run_for_engine[engine] = run - - # Now, when we see a normal TeX run, we will eliminate the hypertex run. - # Since normal run and hypertex run a basically identical this eliminates - # unnecessary cruft. When hypertex run succeed it will be displayed and - # marked up appropriately. - - if engine == PDFLATEX: - disable_markup.append(DISABLE_HPDFLATEX) - enable_markup.append(ENABLE_PDFLATEX) - if engine == LATEX: - disable_markup.append(DISABLE_HLATEX) - enable_markup.append(ENABLE_LATEX) - if engine == TEX: - disable_markup.append(DISABLE_HTEX) - enable_markup.append(ENABLE_TEX) - - run_summary = run_summary + '\n' - - for e, r in last_run_for_engine.items(): - run_summary = run_summary + f"\tLast run for engine {e} is {r}\n" - - # Ignore lines that we know submitters are not interested in or that - # contain little useful value - - skip_markup = [] - - current_engine = '' - current_run = '' - - last_run = False - - # Filters [css class, regex, run spec] - # - # Parameters: - # - # css class: class to use for highlighting matching text - # - # regex: regular expression that sucks up everything you want to highlight - # - # run spec: specifies when to start applying filter - # OR apply to last run. - # - # Possible Values: first, second, third, fourth, last - # - # Examples: - # 'first' - starts applying filter on first run. - # 'last' - applies filter to last run of each particular engine. - # 'third' - applies filter starting on third run. - # - # run spec of 'second' will apply filter on second, third, ... - # run spec of 'last' will apply ONLY on last run for each engine. - # - # Order: Filters are applied in order they appear in list. - # - # If you desire different highlighting for the same string match - # you must make sure the least restrictive filter is after more - # restrictive filter. - # - # Apply: Only one filter will be applied to a line from the log. - # - filters = [ - - # Examples (these highlight random text at beginning of autotex log) - # ['suggestion', r':.*PATH.*', 'second'], # Note ORDER is critical here - # ['help', r':.*PATH.*', 'first'], # otherwise this rule trumps all PATH rules - # ['suggestion', r'Set working directory to.*', ''], - # ['ignore', 'Setting unix time to current time.*', ''], - # ['help','Using source archive.*',''], - # ['info', r'Using directory .* for processing.', ''], - # ['warning', r'Copied file .* into working directory.', ''], - # ['danger', 'nostamp: will not stamp PostScript', ''], - # ['danger', r'TeX/AutoTeX.pm', ''], - # ['fatal', r'override', ''], - - # Help - use to highlight links to help pages (or external references) - # ['help', 'http://arxiv.org/help/.*', ''], - - # Individual filters are ordered by priority, more important highlighting first. - - ['ignore', r"get arXiv to do 4 passes\: Label\(s\) may have changed", ''], - - # Abort [uses 'fatal' class for markup and then disables other markup. - ['abort', r'Fatal fontspec error: "cannot-use-pdftex"', ''], - ['abort', r'The fontspec package requires either XeTeX or LuaTeX.', ''], - ['abort', r'{cannot-use-pdftex}', ''], - - # These should be abort level errors but we are not set up to support - # multiple errors of this type at the moment. - ['fatal', '\*\*\* AutoTeX ABORTING \*\*\*', ''], - ['fatal', '.*AutoTeX returned error: missfont.log present.', ''], - ['fatal', 'dvips: Font .* not found; characters will be left blank.', ''], - ['fatal', '.*missfont.log present.', ''], - - # Fatal - ['fatal', r'Fatal .* error', ''], - ['fatal', 'fatal', ''], - - # Danger - ['danger', r'file (.*) not found', ''], - ['danger', 'failed', ''], - ['danger', 'emergency stop', ''], - ['danger', 'not allowed', ''], - ['danger', 'does not exist', ''], - - # TODO: Built execution priority into regex filter specification to - # TODO: avoid having to worry about order of filters in this list. - # Must run before warning regexes run - ['danger', 'Package rerunfilecheck Warning:.*', 'last'], - ['danger', '.*\(rerunfilecheck\).*', 'last'], - ['danger', 'rerun', 'last'], - - # Warnings - ['warning', r'Citation.*undefined', 'last'], # needs to be 'last' - ['warning', r'Reference.*undefined', 'last'], # needs to be 'last' - ['warning', r'No .* file', ''], - ['warning', 'warning', 'second'], - ['warning', 'unsupported', ''], - ['warning', 'unable', ''], - ['warning', 'ignore', ''], - ['warning', 'undefined', 'second'], - - # Informational - ['info', r'\~+\sRunning.*\s\~+', ''], - ['info', r'(\*\*\* Using TeX Live 2016 \*\*\*)', ''], - - # Success - ['success', r'(Extracting files from archive:)', ''], - ['success', r'Successfully created PDF file:', ''], - ['success', r'\*\* AutoTeX job completed. \*\*', ''], - - # Ignore - # needs to be after 'warning' above that highlight same text - ['ignore', r'Reference.*undefined', 'first'], - ['ignore', r'Citation.*undefined', 'first'], - ['ignore', 'warning', 'first'], - ['ignore', 'undefined', 'first'], - ] - - # Try to create summary containing errors deemed important for user - # to address. - error_summary = '' - - # Keep track of any errors we've already added to error_summary - abort_any_further_markup = False - have_detected_xetex_luatex = False - have_detected_emergency_stop = False - have_detected_missing_file = False - have_detected_missing_font_markup = False - have_detected_rerun_markup = False - - # Collect some state about - - final_run_had_errors = False - final_run_had_warnings = False - - line_by_line = autotex_log.splitlines() - - # Enable markup. Program will turn off markup for extraneous run. - markup_enabled = True - - for line in line_by_line: - - # Escape any HTML contained in the log - line = html.escape(line) - - # Disable markup for TeX runs we do not want to mark up - for regex in disable_markup: - - if re.search(regex, line, re.IGNORECASE): - markup_enabled = False - # new_log = new_log + f"DISABLE MARKUP:{line}\n" - break - - # Enable markiup for runs that user is interested in - for regex in enable_markup: - - if re.search(regex, line, re.IGNORECASE): - markup_enabled = True - # new_log = new_log + f"ENABLE MARKUP:{line}\n" - # key_summary = key_summary + "\tRun: " + re.search(regex, line, re.IGNORECASE).group() + '\n' - found = run_regex.search(line) - if found: - current_engine = found.group(1) - current_run = found.group(2) - # new_log = new_log + f"Set engine:{current_engine} Run:{current_run}\n" - - if current_engine and current_run: - if last_run_for_engine[current_engine] == current_run: - # new_log = new_log + f"LAST RUN:{current_engine} Run:{current_run}\n" - last_run = True - break - - # In the event we are not disabling/enabling markup - if re.search(run_regex, line): - found = run_regex.search(line) - if found: - current_engine = found.group(1) - current_run = found.group(2) - if last_run_for_engine[current_engine] == current_run: - # new_log = new_log + f"LAST RUN:{current_engine} Run:{current_run}\n" - last_run = True - - # Disable markup for TeX runs that we are not interested in. - if not markup_enabled: - continue - - # We are not done with this line until there is a match - done_with_line = False - - for regex in skip_markup: - if re.search(regex, line, re.IGNORECASE): - done_with_line = True - new_log = new_log + f"Skip line {line}\n" - break - - if done_with_line: - continue - - # Ignore, Info, Help, Warning, Danger, Fatal - for level, filter, run in filters: - regex = r'(' + filter + r')' - - # when we encounter fatal error limit highlighting to fatal - # messages - if abort_any_further_markup and level not in ['fatal', 'abort']: - continue - - if not run: - run = 'first' - - # if last_run and run and current_run and re.search('Package rerunfilecheck Warning', line): - # if re.search('Package rerunfilecheck Warning', line): - #new_log = new_log + ( - # f"Settings: RUN:{run}:{RUN_ORDER.index(run)} " - # f" CURRENT:{current_run}:{RUN_ORDER.index(current_run)}:" - # f"Last:{last_run_for_engine[current_engine]} Filter:{filter}" + '\n') - - if run and current_run \ - and ((RUN_ORDER.index(run) > RUN_ORDER.index(current_run) - or (run == 'last' and current_run != last_run_for_engine[current_engine]))): - # if re.search('Package rerunfilecheck Warning', line): - # new_log = new_log + f"NOT RIGHT RUN LEVEL: SKIP:{filter}" + '\n' - continue - - actual_level = level - if level == 'abort': - level = 'fatal' - - if re.search(regex, line, re.IGNORECASE): - line = re.sub(regex, rf'\1', - line, flags=re.IGNORECASE) - - # Try to determine if there are problems with a successful compiliation - if compilation_status == 'succeeded' \ - and current_engine == success_last_engine \ - and current_run == success_last_run: - - if level == 'warning': - final_run_had_warnings = True - if level == 'danger' or level == 'fatal': - final_run_had_errors = True - - # Currently XeTeX/LuaTeX are the only full abort case. - if not abort_any_further_markup and actual_level == 'abort' \ - and (re.search('Fatal fontspec error: "cannot-use-pdftex"', line) - or re.search("The fontspec package requires either XeTeX or LuaTeX.", line) - or re.search("{cannot-use-pdftex}", line)): - - if error_summary == '': - error_summary = initialize_error_summary() - else: - error_summary = error_summary + '\n' - - error_summary = error_summary + ( - "\t
  • At the current time arXiv does not support XeTeX/LuaTeX.\n\n" - '\tIf you believe that your ui-app requires a compilation ' - 'method \n\tunsupported by arXiv, please contact ' - 'help@arxiv.org for ' - '\n\tmore information and provide us with this ' - f'submit/{submission_id} identifier.
  • ') - - have_detected_xetex_luatex = True - abort_any_further_markup = True - - # Hack alert - Cringe - Handle missfont while I'm working on converter. - # TODO: Need to formalize detecting errors that need to be - # TODO: reported in error summary - if not have_detected_missing_font_markup and level == 'fatal' \ - and re.search("missfont.log present", line): - - if error_summary == '': - error_summary = initialize_error_summary() - else: - error_summary = error_summary + '\n' - - error_summary = error_summary + ( - "\t
  • A font required by your paper is not available. " - "You may try to \n\tinclude a non-standard font or " - "substitue an alternative font. \n\tSee Custom Fontmaps. If " - "this is due to a problem with \n\tour system, please " - "contact help@arxiv.org" - " with details \n\tand provide us with this " - f'ui-app identifier: submit/{submission_id}.' - '
  • ') - - have_detected_missing_font_markup = True - - # Hack alert - detect common problem where we need another TeX run - if not have_detected_rerun_markup and level == 'danger' \ - and re.search("rerunfilecheck|rerun", line) \ - and not re.search(r"get arXiv to do 4 passes\: Label\(s\) may have changed", line) \ - and not re.search(r"oberdiek", line): - - if error_summary == '': - error_summary = initialize_error_summary() - else: - error_summary = error_summary + '\n' - - error_summary = error_summary + ( - "\t
  • Analysis of the compilation log indicates " - "your ui-app \n\tmay need an additional TeX run. " - "Please add the following line \n\tto your source in " - "order to force an additional TeX run:\n\n" - "\t\\typeout{get arXiv " - "to do 4 passes: Label(s) may have changed. Rerun}" - "\n\n\tAdd the above line just before \end{document} directive." - "/li>") - - # Significant enough that we should turn on warning - final_run_had_warnings = True - - # Only do this once - have_detected_rerun_markup = True - - # Missing file needs to be kicked up in visibility and displayed in - # compilation summary. - # - # There is an issue with the AutoTeX log where parts of the log - # may be getting truncated. Therefore, for this error, we will - # report the error if it occurs during any run. - # - # We might want to refine the activiation criteria for this - # warning once the issue is resolved with truncated log. - if not have_detected_missing_file and level == 'danger' \ - and re.search('file (.*) not found', line, re.IGNORECASE): - - if error_summary == '': - error_summary = initialize_error_summary() - else: - error_summary = error_summary + '\n' - - error_summary = error_summary + ( - "
  • \tA file required by your ui-app was not found." - f"\n\t{line}\n\tPlease upload any missing files, or " - "correct any file naming issues, and then reprocess" - " your ui-app.
  • ") - - # Don't activate this so we can see bug I created above... - have_detected_missing_file = True - - # Emergency stop tends to hand-in-hand with the file not found error. - # If we havve already reported on the file not found error then - # we won't add yet another warning about emergency stop. - if not have_detected_missing_file and not have_detected_emergency_stop and level == 'danger' \ - and re.search('emergency stop', line, re.IGNORECASE): - - if error_summary == '': - error_summary = initialize_error_summary() - else: - error_summary = error_summary + '\n' - - error_summary = error_summary + ( - "\t
  • We detected an emergency stop during one of the TeX" - " compilation runs. Please review the compilation log" - " to determie whether there is a serious issue with " - "your ui-app source.
  • ") - - have_detected_emergency_stop = True - - # We found a match so we are finished with this line - break - - # Append line to new marked up log - new_log = new_log + line + '\n' - - if error_summary: - error_summary = finalize_error_summary(error_summary) - - # Now that we are done highlighting the autotex log we are able to roughly - # determine/refine the status of a successful compilation. - # Note that all submissions in 'Failed' status have warnings/errors we are not - # sure about. When status is 'Succeeded' we are only concerned with warnings in - # last run. - status_class = 'success' - if compilation_status == 'failed': - status_class = 'fatal' - if have_detected_xetex_luatex: - display_status = "Failed: XeTeX/LuaTeX are not supported at current time." - elif have_detected_missing_file: - display_status = "Failed: File not found." - else: - display_status = "Failed" - elif compilation_status == 'succeeded': - if final_run_had_errors and not final_run_had_warnings: - display_status = ("Succeeded with possible errors. " - "\n\t\tBe sure to carefully inspect log (see below).") - status_class = 'danger' - elif not final_run_had_errors and final_run_had_warnings: - display_status = ("Succeeded with warnings. We recommend that you " - "\n\t\tinspect the log (see below).") - status_class = 'warning' - elif final_run_had_errors and final_run_had_warnings: - display_status = ("Succeeded with (possibly significant) errors and " - "warnings. \n\t\tPlease be sure to carefully inspect " - "log (see below).") - status_class = 'danger' - else: - display_status = f"Succeeded!" - status_class = 'success' - else: - status_class = 'warning' - display_status = "Succeeded with warnings" - - status_line = f"\nProcessing Status: {display_status}\n\n" - - # Put together a nice report, list TeX runs, markup info, and marked up log. - # In future we can add 'Recommendation' section or collect critical errors. - new_log = run_summary + status_line + error_summary + key_summary \ - + '\n\nMarked Up Log:\n\n' + new_log - - return new_log diff --git a/submit/integration/README.md b/submit/integration/README.md deleted file mode 100644 index ee4ce6a..0000000 --- a/submit/integration/README.md +++ /dev/null @@ -1,19 +0,0 @@ -Integration tests for submission system -============================ - -The test in test_integration runs through all the submission steps in -a basic manner. - -Running the integration test -============================ - -``` bash -export INTEGRATION_JWT=eyJ0ex... -export INTEGRATION_URL='http://localhost:8000' -pipenv run python -m submit.integration.test_integration -``` - -TODO: Docker compose for Integration - -TODO: Automate integration test on travis or something similar - diff --git a/submit/integration/__init__.py b/submit/integration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/submit/integration/test_integration.py b/submit/integration/test_integration.py deleted file mode 100644 index 48c0298..0000000 --- a/submit/integration/test_integration.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Tests for the ui-app system integration. - -This differs from the test_workflow in that this tests the ui-app -system as an integrated whole from the outside via HTTP requests. This -contacts a ui-app system at a URL via HTTP. test_workflow.py -creates the flask app and interacts with that. - -WARNING: This test is written in a very stateful manner. So the tests must be run -in order. -""" - -import logging -import os -import tempfile -import unittest -from pathlib import Path -import pprint -import requests -import time - -from requests_toolbelt.multipart.encoder import MultipartEncoder - -from http import HTTPStatus as status -from submit.tests.csrf_util import parse_csrf_token - - -logging.basicConfig() -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) - -@unittest.skipUnless(os.environ.get('INTEGRATION_TEST', False), - 'Only running during integration test') -class TestSubmissionIntegration(unittest.TestCase): - """Tests ui-app system.""" - @classmethod - def setUp(self): - self.token = os.environ.get('INTEGRATION_JWT') - self.url = os.environ.get('INTEGRATION_URL', 'http://localhost:5000') - - self.session = requests.Session() - self.session.headers.update({'Authorization': self.token}) - - self.page_test_names = [ - "unloggedin_page", - "home_page", - "create_submission", - "verify_user_page", - "authorship_page", - "license_page", - "policy_page", - "primary_page", - "cross_page", - "upload_page", - "process_page", - "metadata_page", - "optional_metadata_page", - "final_preview_page", - "confirmation" - ] - - self.next_page = None - self.process_page_timeout = 120 # sec - - - def check_response(self, res): - self.assertEqual(res.status_code, status.SEE_OTHER, f"Should get SEE_OTHER but was {res.status_code}") - self.assertIn('Location', res.headers) - self.next_page = res.headers['Location'] - - - def unloggedin_page(self): - res = requests.get(self.url, allow_redirects=False) #doesn't use session - self.assertNotEqual(res.status_code, 200, - "page without Authorization must not return a 200") - - - def home_page(self): - res = self.session.get(self.url, - allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['content-type'], - 'text/html; charset=utf-8') - self.csrf = parse_csrf_token(res) - self.assertIn('Welcome', res.text) - - def create_submission(self): - res = self.session.get(self.url, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Welcome', res.text) - - res = self.session.post(self.url + "/", - data={'new': 'new', 'csrf_token': parse_csrf_token(res)}, - allow_redirects=False) - self.assertTrue(status.SEE_OTHER, f"Should get SEE_OTHER but was {res.status_code}") - - self.check_response(res) - - def verify_user_page(self): - self.assertIn('verify_user', self.next_page, - "next page should be to verify_user") - - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('By checking this box, I verify that my user information is', res.text) - - # here we're reusing next_page, that's not great maybe find it in the html - res = self.session.post(self.next_page, data={'verify_user': 'true', - 'action': 'next', - 'csrf_token': parse_csrf_token(res)}, - - allow_redirects=False) - self.check_response(res) - - def authorship_page(self): - self.assertIn('authorship', self.next_page, "next page should be to authorship") - res = self.session.get(self.next_page) - self.assertEqual(res.status_code, 200) - self.assertIn('I am an author of this paper', res.text) - res = self.session.post(self.next_page, - data={'authorship': 'y', - 'action': 'next', - 'csrf_token': parse_csrf_token(res)}, - - allow_redirects=False) - self.check_response(res) - - def license_page(self): - self.assertIn('license', self.next_page, "next page should be to license") - res = self.session.get(self.next_page) - self.assertEqual(res.status_code, 200) - self.assertIn('Select a License', res.text) - res = self.session.post(self.next_page, - data={'license': 'http://arxiv.org/licenses/nonexclusive-distrib/1.0/', - 'action': 'next', - 'csrf_token': parse_csrf_token(res)}, - - allow_redirects=False) - self.check_response(res) - - def policy_page(self): - self.assertIn('policy', self.next_page, "URL should be to policy") - res = self.session.get(self.next_page) - self.assertEqual(res.status_code, 200) - self.assertIn('By checking this box, I agree to the policies', res.text) - res = self.session.post(self.next_page, - data={'policy': 'y', - 'action': 'next', - 'csrf_token': parse_csrf_token(res)}, - - allow_redirects=False) - self.check_response(res) - - def primary_page(self): - self.assertIn('classification', self.next_page, "URL should be to primary classification") - res = self.session.get(self.next_page) - self.assertEqual(res.status_code, 200) - self.assertIn('Choose a Primary Classification', res.text) - res = self.session.post(self.next_page, - data={'category': 'hep-ph', - 'action': 'next', - 'csrf_token': parse_csrf_token(res)}, - - allow_redirects=False) - self.check_response(res) - - - def cross_page(self): - self.assertIn('cross', self.next_page, "URL should be to cross lists") - res = self.session.get(self.next_page, ) - self.assertEqual(res.status_code, 200) - self.assertIn('Choose Cross-List Classifications', res.text) - res = self.session.post(self.next_page, - data={'category': 'hep-ex', - 'csrf_token': parse_csrf_token(res)}) - self.assertEqual(res.status_code, 200) - - res = self.session.post(self.next_page, - data={'category': 'astro-ph.CO', - 'csrf_token': parse_csrf_token(res)}) - self.assertEqual(res.status_code, 200) - - # cross page is a little different in that you post the crosses and then - # do the next. - res = self.session.post(self.next_page, - data={'action':'next', - 'csrf_token': parse_csrf_token(res)}, - allow_redirects=False) - self.check_response(res) - - - def upload_page(self): - self.assertIn('upload', self.next_page, "URL should be to upload files") - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Upload Files', res.text) - - # Upload a file - upload_path = Path(os.path.abspath(__file__)).parent / 'upload2.tar.gz' - with open(upload_path, 'rb') as upload_file: - multipart = MultipartEncoder(fields={ - 'file': ('upload2.tar.gz', upload_file, 'application/gzip'), - 'csrf_token' : parse_csrf_token(res), - }) - - res = self.session.post(self.next_page, - data=multipart, - headers={'Content-Type': multipart.content_type}, - allow_redirects=False) - - self.assertEqual(res.status_code, 200) - self.assertIn('gtart_a.cls', res.text, "gtart_a.cls from upload2.tar.gz should be in page text") - - # go to next stage - res = self.session.post(self.next_page, # should still be file upload page - data={'action':'next', 'csrf_token': parse_csrf_token(res)}, - allow_redirects=False) - self.check_response(res) - - def process_page(self): - self.assertIn('process', self.next_page, "URL should be to process step") - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Process Files', res.text) - - #request TeX processing - res = self.session.post(self.next_page, data={'csrf_token': parse_csrf_token(res)}, - allow_redirects=False) - self.assertEqual(res.status_code, 200) - - #wait for TeX processing - success, timeout, start = False, False, time.time() - while not success and not time.time() > start + self.process_page_timeout: - res = self.session.get(self.next_page, - allow_redirects=False) - success = 'TeXLive Compiler Summary' in res.text - if success: - break - time.sleep(1) - - self.assertTrue(success, - 'Failed to process and get tex compiler summary after {self.process_page_timeout} sec.') - - #goto next page - res = self.session.post(self.next_page, # should still be process page - data={'action':'next', 'csrf_token': parse_csrf_token(res)}, - allow_redirects=False) - self.check_response(res) - - def metadata_page(self): - self.assertIn('metadata', self.next_page, 'URL should be for metadata page') - self.assertNotIn('optional', self.next_page,'URL should NOT be for optional metadata') - - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Edit Metadata', res.text) - - res = self.session.post(self.next_page, - data= { - 'csrf_token': parse_csrf_token(res), - 'title': 'Test title', - 'authors_display': 'Some authors or other', - 'abstract': 'THis is the abstract and we know that it needs to be at least some number of characters.', - 'comments': 'comments are optional.', - 'action': 'next', - }, - allow_redirects=False) - self.check_response(res) - - - def optional_metadata_page(self): - self.assertIn('optional', self.next_page, 'URL should be for metadata page') - - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Optional Metadata', res.text) - - res = self.session.post(self.next_page, - data = { - 'csrf_token': parse_csrf_token(res), - 'doi': '10.1016/S0550-3213(01)00405-9', - 'journal_ref': 'Nucl.Phys.Proc.Suppl. 109 (2002) 3-9', - 'report_num': 'SU-4240-720; LAUR-01-2140', - 'acm_class': 'f.2.2', - 'msc_class': '14j650', - 'action': 'next'}, - allow_redirects=False) - self.check_response(res) - - - def final_preview_page(self): - self.assertIn('final_preview', self.next_page, 'URL should be for final preview page') - - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('Review and Approve Your Submission', res.text) - - res = self.session.post(self.next_page, - data= { - 'csrf_token': parse_csrf_token(res), - 'proceed': 'y', - 'action': 'next', - }, - allow_redirects=False) - self.check_response(res) - - - def confirmation(self): - self.assertIn('confirm', self.next_page, 'URL should be for confirmation page') - - res = self.session.get(self.next_page, allow_redirects=False) - self.assertEqual(res.status_code, 200) - self.assertIn('success', res.text) - - - def test_submission_system_basic(self): - """Create, upload files, process TeX and submit a ui-app.""" - for page_test in [getattr(self, methname) for methname in self.page_test_names]: - page_test() - - -if __name__ == '__main__': - unittest.main() diff --git a/submit/integration/upload2.tar.gz b/submit/integration/upload2.tar.gz deleted file mode 100644 index b665ee41d50655de94bb2f6c267ded696e752599..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27805 zcmV)cK&ZbTiwFo+{i;a<128!-GcGVOI4~}BVR8WMTzhxhwvx}kt51Ow*QZV!E0P@B zsrR}YztUHnq_Ml#yS1_9#mdwFIZ@i`ZUm)GJ( ztG;;6Aw3f1hv7HhJbyMEJsS)^BJ6d-{j-ou`C&Lr)yKkgM#)&u0nMZ!1D}Y3M^a6r zr|={1Be!f7eh9_qIQfVeRAJd;%sCfc2+F;L=d0jvLKedTuw>QGVOboM+iaXA5sPGs z>gk~Bf3sg!KQAm)&WSn|&_Cyimq(zFVjCIxV(cgprJNTI^rjRZc3%h(c z%M^EB5=DS1965%%c^;~=@S?2jRqA;KY+msw2=9xx*Tb?%Ra6%3;;Si7MWYn{k4sjt zKka_YAS2rVA3(J=KG=XjhMEBJ$tHNOQGvIPz-_m{-2mTgfr~Am4qZ((*VO=(l)1~k zLfU8Uvv_TFb6wvuA?5)k8c`OR?j(wu+ZFXc@+{!jFPGcgYmSPOFr9K&sKP6}y7t#imUyKGP1SBmY{EMllO-5nCN^8rTmM+PNi zigx~&SCwsdvacZ(`xoD`KYauLhJ$a}M8r^&7s-3J+bA{^WY%wi5UAbKB-q|`!ed;K^#mpZ;B zF1dFiVO$#~M>8#r@Q)9Mr48#^u_G(ivtswGSl^0$Zp8{KcHfE}Td@aL?8J&av|^`L z>ZN#a>vki52_GicM|U!+{n1*^0H> zZ)U~X?N?f{cKcOUtlfTdE7oqmGb`3^zjs!w-G1j*tlfU^tysJL{>_TD+wX}D``q4d zr&g@J-_ER9d%vAqvG#tuuww1~_R5O2_uF??ti9j9w_@%6_Jb8`@3$YVSbM+yWX0P3 z&TA{y?sqP&Si9eOW5wG2&RZ+i?swkVu={qu^RpFex8L_xtlfUEtXRAKUR$wt`~Ag= zwcGE%Td{Wg{j(Kox8EBp)^5MQTCsNf{fiZAwco;V^AMKT;ntls%d%%a2>P9EQiEH1uqqL*rFs{W#MFtfim5|I;3U{NY0?Bj(rY~EvC zEiM@kr0DO9x!&#?ZY0werBx$eo9#&um(}X=uU}h{$7W+9!Ughpc@tBq;X4i+<7G7v z&EvtquDNp&sxFeDo#c8dI!Q)$lGi+P{Vtk4JIzhdfq!ntzw5&9+woVyq*GZ3c9LIG z8H7pPLDXTJ@1|XXz33p2T?F6Q3EoCx($$jXQAUSGhy4R{R#fbl)k&O8@Nm$>BQt#j zlS?tq!}|DThfbAVe|ZthKulshN@5jcWi?!qNl`WSv})|Wh|F9wp5WXT^0^a?r$#^WHb@shXB-&%)6-Iq<#!1l z>@!dJ(53vSstKOcBm{*yQYe_G2FH530-XVOKZu;E8XOPBoYe}w0EN;ytq3J-?309t z& z3FMwm)J5#q$Karch=-w*@(fkV2B~spn8IUz5I4>x+lM^Te-F^1KI(R$|8kW>wopzi z=J1u!i8OkVI4lX&%Iab7;}N<>i#r^}q*K$KPaQJrmWh+*K=q&k?)#7{QPL6w$%4sL zAsI-_(h$V#KX;}QJUbND=lX6@I!fX5-(i3ycOu*CV{@p_so%aF!d_L2`&*T>cUXd= zs^k8M2ch9c;-WmHqWxM!d%&`Wf`E9Wzql~SvkmDHsHw{u)b58~b6mg6k~ERRKXN|j z9O+Y?kvUVCjKUW7z3@aZtFW1Q^0^X@n+bl~0$&La#&mcaz~b(DIdLSTAnHq{_!*H0Z|lQ&mVyLwbNwo~Lx# z<0i>%#Isu&=aCBo&OcUJE}piE7Fk{rCK*_0GY=mr|JV&HSfIpG7On%9vS6g;OIaz1 zyOagJzYOxnFi!4**iY^jVn8llHLF=5?v7st>h$|+_2Tes9?WRj;#|I`TT65xprGoe z)D7G)NSK%$H3$my?EUYqpACkC(ca#3&>oN%;EYC}j`l`_(P8bV36I{>>W{H|$?|~# zEY`=xRH^jn*)yW*C)wl~cjYs1wc9X8mD2Z@M@}kY9;$n`I~)uQt7;0MW3PS+{nc%&{I%#^_h)@PLaA{aE59 zw!Rd0?~R=Ec|%f@bNL>-!d!amtMI-n&600E|I6uXsG_VMe|&Yhczg*KK~wvG$%9ct zNC;TJh8cEV4zs!#Yh$UN@R}NCoO|YFxi9*R{h+W_5=KBCD;PjHNBMzNK|INEMiuca z$P!xlN@OGxuq>`v07H`Bvmz9@{n9+pUL?&J!W_0%}NHevD!GO$dt_~Tn$`|ol!4>o; zkXXtAfqm`0BpKk>$Yg^WUbL_<7taa1OS0Qag(gy>(bbHGK$J_xrnGS(C{EKbF@R}M z^o4VCb7Rbk;9!z4$b9UeDY~K%>=&`7v9N|THu5QP>lGTwwqUX~+!_^*n5RI%$Iy1Y zjFY;o0bC5tVrx{*7FTF5;4OhR^H~Ee(bC8ER-(`3afPOq-6uJ%*s6FESX*NJi7TK* zPZ=ytKp`@K-2=~PcY=ckXfk#!ZfHiJF6}KhZV?GkTvVy;hp2IqD5zDGl){7sgl??V z-!>M(1Qu&$@k;+%5Q@Wk9!#EoEak8;fsMjD{I-*fLJe^VtP14SBQ|JRH(t%k6Sj_+|50sq)=%h+@qSj(o z17K|%*KNAwdO^`8mj-VD1TO+^foJz%6Lqo+w*h87WDTO_!aK@W7q2LlxMwE*S5>ML zwH~t?p*I78HvM;vhZWcjft$fLLTv;RnY!0NN_Bx&LZmlM!T?9gRh6=-RS6L33csz^ z6Mq69LXPxok?uxr0$YdqoC~Qt@#iYl0onkv7O@(zx(1r=jlrES(%T)#O+Xu9) zYl6Ae%))P~lpVAVu$lw7K-grK#e4m`ZAYvrUs)Wj=DxOW*aBA&*@0ODh}LmfQ!eH_ zN^u=>VG>=KHHftU9<2kcEa%}Q$pV_!*5$@ z$OEYOjDD=;t4g_)d6eo=b+SlzBiF&eS9)PCiQ(BI-G$r)v;oF9{_LzycH%Ywt%pdJ z{+Um9km{@29U$*5f|TACeeXe2UuCIFO;Y`Z|h_SZXE=6bJ?t7)_CZ| zZiw3iwE<==1cum5#PtBL(%r~SU~4h6wYA;xEDmCrivDa89hj8>eMa%7cd})5Z`syoo5&QU#O8b}}OEk$Tmw{Kkb#tWiCPJ|^3-Bgh;DH*RthzvUZgnFWIUEYx%(VX)}yHB%uLR3)ZaIGz$6xTbg(RyPt+Zk)Ab?K zOyH|AJY;l()vM@^5Bdk@rfRRhSKaC8qzTi8xx5I2XfC&#u z@t&Zf#r~@TJ-F-Q{Sysfw*0D%3ri(D-h=s3C=YZ#><{)!B4^;eVwR_jGsS(T@5{X@ zzJSy^io6fS;;?teNkNl%s7P`aoxr~0e@e4zVj`C<66 zs4j4JUknCB4~iON6asNEv`(Dpi@Y`q*o$ZuO|rUA&B)Ns;=0)geWsiVy5v%K3BKkA zJOy4`YJ{xZ1DFCHLLHHYj3z*%)yY#Jw7&3`ni;J!e4m17QM;iSEA}aeZSOb`{!{2_ zG!Ez{^mkR{gMJ$^OQ2Z{FXd}$a12V&;WCuVkjDr`MD_OHV%rQd?(i_+(sbk#-P;QQ zh~4aB6q#5w#oj>VJZ=e1ESi!%lzY*xKB2Yf?}a|$*DqnxtACa5N#{?SZ9|?~cpMzx zUBA=N$^fg5!MuW~VRY^FN5*Gr?@2zBl4a5DPw9FQUo$JwqVUJK4tyyu(l-QPWtgU` z;DT+9^dl$-OfsqsaA#}piM|e8mauu&@!t^>-?jP!#jK7T{`ZIoW^=CJZ(H(8M?J@4 z1Iv>YG2lK=RnzETK@Dq$=0#4^#1`*NHV_5Tp!50QP!e17ksKe!AfzyL{(Ny>IG=^D z-_q3YW#PM-%uz`SQ&KgpQGiMo%XrI!)HF$< zO1#IaxH&K3Aq90|*hpzvGW`_iYWk&w=>g_5@PrZfvV_%lY4USM_h*GO(@P^r(7o|A z%Rw(pFERRCU>m)EF;~hnDxx(76&TPK#-UKel};_gN@PHJ4W?zexJ;Ly3zTPnzih3W zFg}D)3=%VKK$i#2-c824)G@Me;@qGsbQoswh352ynR<85wHu-fO@=W*h8JC}&TvgL zL36QQn=S-FgTkpq+p_FG(RX%ObskqyBNz~1zl}utW`satLJ}&or_fT%DnmY;guMhq z@+i8gzaQle#)ZT%Jc78M7q{6NW|}wBQ`v8Gh;eNO8Mqc+3I@ct>^(v6s|&rpU^{@M z=O@c-LR?pkgV03bG}*iX8e9x5k>Jsbqpb}~TO?<7wpK~~E;|kxY36Gcz~Zm68hR+4 zm%$jr#xS_ygQG(asl<`cwI*HmbZALt8Fc=8nD)(HKtCt{(q}MNQRssAOnlnaIIKxLC-gDQn2L$b6vTTJB+K zxKPX>$|dn{Kbqq6}E4H>Bt_s@tG}T@o0ZXqpL`<&upw zjZlY5)+VSQDU=AeK8{hm4&Bson7C4(w=7H(31egWscagViN|akna-p(E*Y6Ac~}9} zJ<~h$I@S>;4>G@D9X!hzg;!RMqKCRkzb%^d_eSe50IX?%3lnIym3ZlfQd0k2hGm1P z;z_Fqmz${Jc@^EMbE_cF2E8Sew5W}Matj@-hDntiVJI4Qr-jv|P1SC~Q)Xu~4i;L> zv#^Rr(=o^x)(Ey$DHh7HYc^MHTbDqtH3^Bb{6o~(h^IBmfE=U16`E^eo6=TFvuXQFsa8F!IPAHTOH;A|zG))X7Q~XxMz4pMVc~MJDiGF)Q2ZsY~kJ%d7 zXkpD`!LD55-omw7S2d6RT8^fQVE%wbE6#>N6bV1Ti19nTOBx>N>9Dp8;)lSBs9@D{ zqRxT+9PShrc>O{`74s+aG==F}`2UKl_)6yu8VvYYaQijMna5*_zUGxWdFBRS@1SSB zpTMK;`ZO@n@rW?sqk8RN6%Hz@Ex5S6IvWiJga2&r%lq3nt_1h*`V_tEPDqR)n1?Lc zO6ajH+Z45S;+5pdVK~|b*Z?~O0U8ZjKrq?DRTYD2cjt`_cWogJKvGW+X{onr>c%Zcm*xjpXc6&7kYrEU$)c#&tKpsK>){4D` zi|MuF<@}FIH1Q7+&uI|%Up}WsC~$(9jfZzz&Tpy(>jBFpL}rkl5L^T5o__xu%WT#q zrY~dVjYS<($ayx-Y}XJlX0d_YwG#MGcv{F(Ag7k@>NPG2RhaDZZP5rgY)>&o+VFZh zdpjvF$Ebd^4;JjFdkG3?*NZ(Tbg`|bi}~!5?9~uu=9|k=-WGP9!3->{F+7T#iBpOZ zL}SW4U4TRIcrE4-j)-~#kONVKPHh@Gqtg&OR+!1Ej9!*H)1Rqc?;U|9{ zOyWmXUEh_2`Qo2kLPU!mtX$amnM2ML@{yo3O>jAWOto?mWU3hk8%O{_JQAUl_$kLm z#*Z=J#`5rxvvF7^f4#LQnj%G$*|@P2?WYH(kh>|^ztQGGI82W3$JqpeDqU~9bp2SZ zo*(nEhHY7~9<*GQMYC6Us9GwRike5i=yw2j>;MMo$UT7AJbmC0)^y9Pn4GvgQ|&;T z9k${ps*S`ZN+TVs7&OBmLEp;73^p6#Qvn7JKa>}BIO19df5DxfRghEi8s zg?fq2?8T?t3)WBYgkJ$)E0dW0hF%g*}7*Y6g|_sL1YrRm{hw=?}a@ zbQ5ZCAn?+N$rlG^WPFdI>tUePq=4T{7JPD)TrrKsBm@S_jD$A3aZWIhS>lH2tHeD& zlwQFUqRA1f0qP+Me{5Ff$vh6;+8u8AL2bchc=dtp?s7Klfn@#f!`WqdmJ}uxDMFb} z{hko-uub>SKkl$1W4->RdjCzP9#iL=m}fOQRccH$ef+a*pX^@0im9yaU%3x#e31K& zOJp1Ubq4fuLO@>w(ss5$M`|JTU3;J)NLGt1?}P8oIayyGoZQ^y<;+q7%Z&Gb-OmjD z{JQ_&`~N++bk9bI*>N0avkxwqXEE6N*|XGoMzA-BM3c=>?qtkSXAYOM>L_euuOY?!rxr&UF&fW#KY|ehraUuRf(S;H zhlpMX!w_3uTHY8A+nu_H0f38rVPFmS>Koa|Ct1kTY;fkL#YhYe4I0T`E_;?mKpfBP z!e;&X?=PNY@{JBByDzBm_Cf-ug_1oWpb1ns8#u!47bdAHoT$!{U|{ z6?%4Ab_mObg@LAGZXe^t5LeNjT`oJcIX>!B?&-bb@7_E4Ui8BF#-~6uh8Ejl794B{ zxm3s#!xA1V$y$+R0rENE9G3>5TrNHh5&@v$fV7k#A1&-&^L+^3i@XCi>%a#D9A}^p z8h-$(%EuNDli!G?&Bff}EZZ2|X$`BNKegOXh4@EQPj>HuXD$;0o)7nh>!xjSlYoNBLDehH87DY;Tw?i#5F@28qk+ChNbw^uK4HaB>@1 zBeiuamCfvrC$oX2xP1p0& zrL4>V6fm1y@_T*$m!*CIy@W@Eyg;^Y{ucN|@H26W@Pm*E{A3eY%iZl?-_Q1F zBDtj+_;xWHZhSI;&s2vSr_?;RD+FqXf-Wd!EVEV5rjWwGt1mx=78dG1?ehm#g3*_? z`duf%(t40j!56Puqa=A_Mc`_B@=;Hu$CBEUOCZX!hFk#cSCJ~6sEUqqG%M)yg1a{I zuRAwj5I7DdBOgM5CNN?EFWIO8flc5Y6!+#g#8==0aJy`6-E6+2ec}atLvozRK{)?+ z+2_n1hb+x*xceyTA^y4!S|yH!g!0Nt<;po4xd}UIdo>5uq*|;9ytyWc7>_|^CzyP& zs?kwTrQQQ4NIUz}@`k-6ffd^*-#cc6s_|tthqM}!5fuo)>~@QLVCWkNvexWY3NDc2 z!E`pMZmLBsT-004c-VhiU6%NXpSC={Di-+6{wocej|P^rvHzC_@D-8+Qbt91W;qy( zfWX%h-0Nv+oJM#sSpP4_Uf(!0vyVhxa~XL@@_;^gzH zMys?9Y4vkSj<*-Dv`NxmZCttNVu`LeoZb6ntbo})EbtgOi zDEs};-U0i@(NWO+6cUBvU|rJUjTbkT2|#TG1~%b$(40dKo6GFO-qErBU(m%W1c+?%2|7)<`#KdJuT(aEDnC$9dV{U6l-`|#-DyZV3sh(FRX`qwEjB#tgILI zwiFwSNURKZzdXVAjmL zMRCi?48RUfSWycm*A42WF zPWJHO@q-5sKYWxy9?bREFE{JslaC*rLYW6Y)AOf(rk`i$#h|D*T@&Tr#5}6KzXEkZ zWocavtAB_8oY`A@`rzS5kNyZ_o*W-{PM#c}K6-NctJ7x>A3lEa?ELAM=fC=uIsL+- zzTUIn|LXL!lLsGftdQBd=y{o~zuepnuj;Cp^3#o7=F{o4XDOSV8Z9(GJn7__9SoOy z0?U2;;K7H-M{2ppAAS50RE|>1{n{`0%YOF{Rsy{OCogq4=l$*rJH~ubP2K5yZY7m@ zX-VP_cM!imn~pG;%ua@l@;G72^x{Lul&Q!BFq2*5R%f{zIw!LSK5v;mmT z7q`If!RvW3t$W#1D}(m*VVv^)IO=2{S$H`|NjvBf63}h^5f>3 zzd-#jM^uaV?ARD^rhg@#Fydfk0hG+XSYWmqu_=t4a;-2hy_?#ToPQS(vs8c2wD$y*;i`UvXQcvrIH)8mgxY~`;|u*`rXBnzramMc-^}; zCnry_FweF&A3l73d<^RmM>xxdc?ldUMxeeH9o=k)z#4*=6mG3Jx2;b;w$JSqk}VgT zw#xe$)IR@aHnF@jfXG$XvglLG$7Wv^ z^TEc1-k)%8Fr=j8!9hceD09`4_6qTVvoiSnl^knXIy&OvZ`kih0eUJe*Yy|0gsg2Ls8@7N1 zYSe;~?nX%@RGN1Y(U)mN__v`IN2EW*Y;2_qNHR~EL;AsD2{h7&+9n&w+1Ny57w2^V?z z>MvFpq0#}sC|C~E$~49@`wuYTVF4Mz90Os2)W}!@GMY8XE=4nmY`RTr7Z}kFU z3ABkaTUcLV4{K@Z*MxW7v~2a^TgV**Ui=G+OtSA*G@4{ECg;p-Qkdt({I=}S4r3B3 zKMN=oVdWe9B8_sM{ZgA(2TMrSOK4hfIcV9)A^RCP2Cj9F68w-!# z65q+YnfuWKYvWfF{Q72nz2$w{4Q!)!1j&AP$DaKw+|_OSu=C>X0ExfX-ObN`yM5r@ zcQ?PzA0((_ZpmYwzPAK%yKccRcX@+rYCJ3W?&|)8tGoT${v)nzA8O!%!GUk)PS(>j zmYKShS@Tw!``^k0duslx-AX!u&%W5m1=Ez#M@AXc&Y;)og$^yp>(+m6krle?djx7H zF`HLbTqEcP|K4(r4hdJ@a#7#Z2JxkrPWjfo#Rzq~r|5(S-QI!Gaef1li5Lvj%lM`n zyy+VM+WENGq`3t)J7-8X0j}Bo=RLfS#flD%FQ38_)yQBfL9A{EdHrbDvwYcIWqp8L zKW99HIXL%M^@5=A7VrUtj@cg(9B7!);j(kK%6mo+E`khCf<=;$b@Mf5n}tMX)J$px40`Nla(o#SXROgbvv^d6x~q)sa}E6{fSK3`M83v z9Nd@%e#96E`nI;C1pv0tv&V}KG^^kI(p?b*_Z;>E-~dY}qGGH>$99Lc?EQITp`{Cj zqwB8yJl)v$G#CX}DIe1%(syWlXkV)~owE^t$SLV344GGXv?zNAl+GO{QmBP$W-Yl; zT+J1#`P;WABBzvO+hRG3xIa1GXF>!{wY>%PXhk&+xzI;;nxRuaBWHc)WR?X*>~q*L zdr2^P(e|kYm$Zf0W6e%~_4RaJejB=R?Zh}SPB;c=?}4BIjqAD{voy^i*WYabKLJLd z1ic9Ln3ENVEhS}T&cK(8aT63}DWv)ek$gaEk1b(!H@inn%uQ(@IAU)16~ibM{*NFQ z(Tuw1BP;#dsW4@>??VQm;ni$ba{z@ads$96g$t8xLM4c)mv{{MDrXu2Oa{)QWwc&) zF_k3eHDqQPAtI}18_x0052mHvsMRLsuI#cWn3CcSLj{Q7DcsY|$_n+;_dqgT$fvUt z>S@jKx#TB#a54rcCRKi&7L!%&yf5f=aNG&rKhbYfY=Pz_SNKLX#j>yvzY@HB8Un?a zci=5Pw=7H9ZD=1ky;t!5L;Zd?+-jofMbnXFFHEC$x0n5%I0CX4T*2#}XV@|wflA70 zMSKwj56{PUlKwiLaCkV=;Bd&_)NW!v?T^bJvWGceBuukc@>LrBok@ip2;V77C+e0FS~L~Y?Ynh#810+#~|a;TVcQp$X|6sWl}817C=05m(`C|F|k8k zFvDKZ+jGrh$%TOn&#WJ#5RBj9a?FlPp;(#gGU6pd*(>C8?ja)oEAcn$d-FG&-ac=p z$RyEzMU@b+kMsR{Y@@5&dTpof0GG%EIGV;Tu_-AV-CpoSyZ7UfA24*L4itruV3fB- zov<$n9*8h+B#X-&4qug}m;b>k^7|>UF}4Fpa0W6Xmbh|~4=RFif!couZ`OuE?O5uSV4p(8$9z&O$7>^l5Y>MEA$pxMGSW_{Q zOJStFCgYh0_19_!8CwjP9n0FX1X) zzFFfto5MY!p>t-D=(4}IFZ#)D=q$THIr|h{^rRtz~Y3l%~PihE@b+?uwa(|F{vT8&^9h^ZVIg? z{-Xu03(=F=9@c-{=${#curnUfxT^p)ek?_1#S|X;;lgglZH2@K7`aUKYk$kTa`@Z` zySA6%uVOHqE@pITD!^D*7_)DY2VVKTZ%a4B!gj?%ge#n)$-~K<5s=$YUFwGb-G1*7 z#m3bQf$1oSDgX%tU*}-Ey6*Fz5?f~|paYg+aiisUHw~&rJ8SYQ_4YR21WUW+S9YF< zvgeF9VTS|yN2qJjMhCab{hBBmKYt<@KV>Hpiw0sC*ev|z1t-vQzF0ApAK!1ett2zB zs9cs#WDsO6^dc~{1Trzs8_JM|EMGo)vtDeNvcaTsaR0@n$&uL>N0wi!E~5!rP(vmt zvap7E0 z^zUUx85H;};2bUQQ7Fn{KieNYYxTgHpCU|18LjT>{yOn`KhhwXrn&6PH|r(9X?B4h z2>z6sK3IcsO8dIozL(hL;i`k2iRGE|#LRN9Q3G8wjh?$M4yBI{SrfOnrpC4*K-DHz zH1vN&da!=*A>@`}%u788wbl4llhob2%L)eMzm|D#^HX3%amBk~z@Y{YSb`+bwn!Xj zp_Y_iue9Y z&;{udnIum61}vy;HYgM4p9PjU&}sjj&_274Fq&h<#!p9ck%Amh9Z%omW7W&D&(og3 zB$jycs7(S}%wNeS>ODDlk_g(+*s=#pSv5=ty-Ac8hhxyQm_v8#)Le?hTMSlNgZE7= zw~I6AsQHHsUE84TWBqCCSPL|Cp-G{xK+K~3@?=hFWq`%Udw~T5umTJ0X&YbK%bwYt zu>6l#AS8HC#j(*b6ON@j$d?kWi%Ll_R2Flh*>8-xZA%RpKo zl%UlPB86W>z>EUP0*;}Dw7^exs)r=Qz)s}j#E!dZ^Yvm4Q{=Ycon9XHf(6Q3<*)!7 zXnJ=-6AOu+fz98zvl*0&x1~iX!RmNV58bnls94aIlF>Uk;zdC61eMgRDLBJKkNvec zCeP=^YO|h9HW}z`7MtwXnTUNKoP6K!8yWEMWbxp*!)O6_QJUI@5OhZFnb;o{BIv=m zH1x?&8u~CAo0i-<1Jj&A2ID_Pq9?q7+a)rU$)=+vu$Y3g!~o?^$ZbDkw-V5sfOZr@ zvkAw5(?rPzz4%UooAhltRmz9PC2KlIbffzg_Gw!0YsQWhL~i-bCG0rKk5iM=gX7KS z%>R@iFMvhCWojJ^{UjW(PxtW#5$Sy4suYBnCft#;3rz!CVx;W-UWaS`#TC7M5M~p?h7{i_|681-Iftz|SdsU^kk~D%AOM?CNU!9!2LFIOnq%~N9 zB!2@@>g&n!*=F@IAxLbHZ3u?7V%{Pa=k#ab$PmiHyj<*;Kae_(K zf*svI zG~K)Lnd;@<<7C}y(fPtIq&rXVWfBtJ7O_`XjvWj5SZU5u;*wL~POThvSq=SJQez_j zZ=9{kv*h@zr4r2pz->1o!Pj2hseR(#tn0*@xn{@QP=e{aU*6W$WH$ZeC`Xq%)`v2a zQok?1>#iu#LKBpD{^;EalznJ7YFaFmtfF^Xq_1!oq>=_F@2RFbMdp*j$>+`;R8X-g z_~dBZNF`Kb`UR&ALhKjJ%-%+Dm?%7*g}RYwP#sj`v88=bI52gcx)gZkkGp27fbJrk zvL;t&f&L$0{L(-n5QH>FoYY!&5#5RrQx{}w3h*_`+8{v^6rm;Y`-Y;dYxY!#L3c_i z76@$qB5qfPaUVZTA@-N(Ca0BR#sDu|Tzj6iNow$#U@izvQo-SjHXm*E`HFz9fT98# zq|g~7vV{K5qJe_0yxb%a&eKP8?T+RekLLR49nJMGa5UFH=V-3?I+_Ti@iICmS*++s zE>CzVnx~I$Gpz)etgWC18O@99^_LrD9j`Zjr{5Eier zZ^A#c5hTSOwhYjbrXbb5s%|PvjnTh;*VC_;D!V}2%;z-@VKf@429duw?yU-~#g=Q_tGy8frFTwM@8@R8RKY;T( z?^VnZ*!A*WgSUp;^F)LQ7MzBA4^^uu|1JoX`|Q%;vb*;dQLIn6N%(%l^4e?(4cY3d znz2ZpAT~gg0Yoihbs(x&F*Jr0+}RTB4h_MpsKqY9I(m~|93-tA=I2>C4)UJSV!&Qz zy{4a|aVHGr#P1dnh8H)Te0+G(IkPKaZF*_+?L6?;lQ1n`!LdVFW7g&!bT)_l*&buY zx-PncIjMmd+7Cob_!ql{2luv#Ui9}d(!1}RgT^DqHMCvamf+RU${0Sh3&+#3Rr z{Lh>h4-3tMgs(2Bzzw1BBz=F4h|W@6VcP+fd?qF*@vUQ8qHer74Na`O0^W+|}=9i)tu4tTyIAhvssJY~(_f zA6vTG_9X~~Eb4n9eH;Yuy*IBO1dEOwz7MuJ^ySBvasDBz@V_vk#0ocj4i7Xi9He(S z*Z{SBr|1l<<~2C^>JiWkmg+c9G(@jYV?*;w;N&46E z*2E4+GO6s`80_i?;AFhoG>(UuW@^GmbJRgCG#`za?d-dUEBE`ZqYL3`u!6W5!q8BO zk4HV^VUL(&7K0s+<{ww1Y+4S>x-RBkvS4+GGtpB|XYOTox@?+Clqy`{NLLumgeFg*mqSFl>ZqjylR0{{cP-eeeufS^yk+*=H+deRX)Y=??xSN|X3j zi|g8U$|LV;`qMh!T~tVQ*5dk@rw3oQ@<&3tlc!6#SI+Z{p=RL)IX}Xo`%%os;wa7! z5Gxb)-U1-03qm$7fZ=(0TS^Gx?R*9x?r-F3Lt;Y`a#xMDX-_k302A8p#tN!MiEWJd z#b@miJz`nREps#{%t{#zxN}Zy;J&q>1TR0*7>Bai9Y)u-dkGC?HTh1CZWTbDBbszm zfSj%T4Y4TFUklMeL^DCfhntinZ>SS9<|R|-WZH1bEV5Di9qDD?+%2%S20%8taA1t< zxWtADofJ9Ar(l=i7LV7no~^53?h}3|AQxB-fLJ16b-JMl6FN7jef{y-8q3&PkRe|0 zDJ5VExDp`3`#2y5{3h^_(^e*ua09zRDzC^}(eS`$$_#WN8MZ9o?r>Xe^8qz!AJF(1 zX?HnkLsGyxjuK_aDsCIDonNuP;ZdANVNgc)IB|+WXFsv~9c@ysKV;7nhsR}<8OtRn z+Zo)5IPj?HH<6E(Ps+&q&5fJ!OWQnb2|O=nWj#Guz}-WSmB2}sA&}ez0xv4!k3whP zXpx+wMk2aFJ;ia24nMsuR)}Llmpm|rV%WFu1R}E~&4UDTM9}#qya9X)5pQB&HHUMa z>x>OsH_6B*byW(I?Rj!k4E1aF4$NP0*g+UfgU*KWe!cb56}{EwX=w5|*N#Rrt2pO{ z*h$6Nd$$|5NnOFFyQjcP>?CL68?&O@vf6S}g1DZGMN1L@#C|^hTa5N((9USr!pI*3 zdkOjFHfN(lY|zQ$q${wlgu}E0aqmM$UPeSXGtV$n1MD#k3ypAS)O1 zQTqOmauubyCyJ#bg=^!EwJUB0?DwK(GIB~8oRRyQH`7Gr?nh8)GEDo+MEmDY#VNaF zOZ)6m>Y9){BQXXRD?zpts51{`+4#CbD5uyW)1=D*#WLkG_mg2jmPY23ku&Z8U4X!B zAvj9$=-!O@ftVCeON)31v-#C*W-r0F6#KEt(+;nHqe=WtUH!ko9(jacKi_hOm~e{+y240vPOJpH6UW8x2?4KtN);4JZ*B@l zX~ZD@ytu0`XY|ZeXtwX;}w)yKgwy7yKSk|2LaoV$s2YrJUKI znnM0FsOd}zb3s@DX@86Ivp|7jz*uj9--fw6IsSc^M=un?CKt%ms7Y?^zQ`lMD9#40 z5jg4iQ^p2npiTp7joQu=OOm%2E=}^ z@_$|xcN_BsZr5~1Df3$PUU6Bn$n-V^sdiY)L^mv8_TbL1ifrp4q)W{ zFrnj7UePurtf!19b*f8`CV?_Ao7E)uujoxswcuVcJ(KX5cwcevXrA{HS3L8Hd=7tI zF?SH({ImWV=ha9D(T-F-YawP<`!Gl`2N$6U7u(=GysN>avZok7$x+5nU4(IQ3-ao` z0uM|TTN47q#fV}8^rBzE!B*p8acjrG0XBRzlH{-%HNPd!UG*CRnh@n`aV!BwEVtu2(XuK%Dxa=Q}t_Py*O&=-SBwP9Vhml#vnVl@HBEr_b7D(l-TWNG>V4=%~1X z!rvHJb8`zFLHt1^zA{9V>C3+=x{pKmB$U+WNzafo9H6Vt+Mo{j3pzXhF6j zb;ZH29@D+fW6b7E-ZtlS>d`a*)s$FX)<_jTZY7r#qM3R_$Kg)V%`JD)ew3ee zK`4nkK}ls&-Bb(mfT6RX2Z0e$Hb=&(gKX{;Km8(@dEr?N6xGt0&;?Yx?2RhuFh8_x zT}RAx#Nw&d@}97Y8h{DRe1lEsgTX(c5x?*jy#YNLI7OLVf49h@{nVy?v4+{cTnp4j z^MeEX&Hn2~+ngbQpSi|jCa;~JDcTBfG8BSdNQ!uwGv%S;>AL)DYJxfviGo@5PUM&r zWxou5vR(Fnld`B6D(PwMcmZvx;M3`{!otCE&*yj18tM?P1*(Y*PT5%uIcd7YoRT7; zo1ET*u|th7I`_s*k0|H(2ERfk51Rv{=MOkCrn3ObYIxY)OS?~L@i$7sU(ZO>o)mzc zJ6&V3UQ5oYSc!yaxQ9%Lq@2LwQ#XZ3p8T%c)!6-@Exx- zs}GMiXRn*{hbYuX5Po`w)1!U;`pufU-|z;T+&@CgIDX9F&RZFuBS$R# zg`J>6l2^c!x^sK_b+GRW7?c^n z5zg0mIhsQq@>rd}Ofx;+VWX*O&dUBewN3rp@Bs|xh(G;sW?xhh@$IWMZnf|z5XsfH z0)u!npd1*80L8J&Cq?;f;MpJ2P z`hyQrh%`4$u6jwF*?(*2V24|uZ+?3o=o`?09{4Rp3FaUTAG&|QoW?WUzQ7k!^_~0V zMzE(y&U!|bN6sGhj)R(8M5&1s_%azT;XhqFvj_Ps(uKB~Rm!wm2gWjn(>KRqe!+!H zzv1!)zy`g&noY`GBbl`Ak?uF7mLz|lfVO&rI2s1;Y8X`Io&;>p7DyyS$Vdue!GJs$ zwi^AW2wDC*MbnLPo#5&5^?r@%GM8Ql;)U>-^=bFS4>y)0+_@%6(I_F+bSDY8#07d^ z@hrimgPd8%&W$dhZse|nt|C!_h~W68k7EjF1_@cb_$oG|yNh8F3^28`yD@rUBgQcK zvh~ot!FfxLKX)Qo0F~Z@o1DN77Z1K$d@r6dcDjFA+wY*&`C&Rc{I`xuUu&N`R%Sde zfrc#{NKC+c3xjh3SOmPu5doQ_voGH)VK;i<%qwURo=rPvqra?UWb5kY{Mm)$PKxTr zz2}TV$pmTWyx3k@>PzRc7M#1Dz`{)cpc&ue*BC0euh%N|#8Qw}eK+Sj)@3AA%b?73 zp9Qc&U0sYkkL9*YfG5Bh4sxLFf_>`k{1GG?OzuH@|Cjaqm`#KClBzE#cNtd7`nGb>p^PB8Q4`^u zR3nvo#LY@NzMEE1VhVFfaFHN2o;?eMi>}T!MI8;wMd5ESOd-Q-%*>m@T_I-07tb6Q zCYy$r41cqGiXboh$}S0qYx{tr{xfl^Q*Yoa%aE;e+pN59qe@l&g1es15G)We3b@Km z`LXO7b(NN^{F3}}$k!faeY^RK?6=3D>X7V7EqA$x@@902^xYxNF_9Q)NGpv3I^d_9 z&KaR9bjOAlQQc`jXZg(;s9wVAii{O4%-wXSxX}Tx1`7g>tV-u;;@!9mlUl3F6Q4;2BhnB>hAyg_|CcR^6_K-%JbMG-he(6AtH#Rqyyy1*h4Z$$iA_wEcDfbE!uM;5`hi@{LM*#KJ46+3CIY&6{gi z@g=Rg?uHA0b0nsdyPBQnV9n?Tr)NYxi&jsiXL5+-4Y^BigHL1uRc^y#!TvmFHMz+j zbW%11qAjAn=caJXFdYEx!3@gtTzYlZ+u7aZGMptGvAbteT~D~JYpj>SU3*uj!V>$G zML@lb_5)doy}t9NN0b}1>$ys|(fB&BnBkKL--X|W`Lv)E|3V(xd=@);0d@^o{JH<`Rr}!WZwqdz{)&~<>@hfMoQmwF`G#a z*jI1XqtK^;ZAc&^U1lnm+K9w7S>%!}IV0_gU+h7h!cdL&M1@y|s<_BbLCdTze z?&A$pksEOr4%ZjmL0p3wZEiY)pw}t7z@rz;9)J`yorPOc;f=*PMasn4)Em&zBuD%A zB9Gv1Z!l5C@qBtaR&^=_NUEkqfXyNZQ{_rRlM-5xBeW*{4ax|;QM>43A&pTgYPe5U zkUR4E5EokFbV8L#hDsF3$C>fuA#0izj&hzJ2!zeORHQ}rInY|bPzps z&N!pk7Fig9?b|n_ObqD}+{iU?IgmuYtD`F6RQdZ`1n0O!$CRwL-WGcSq8hznKWgK_ zf8s72UUdEw#Af1X~+8r3`MW+L6PqKZWuJAhT>*t7jLF9XX9Z#5D5-# zQ;@_MLkJaDTuFFyW9d=4qE?+qs`744p*^0DB8ZNI)P9q@OCe7G*_>wE>bp`JYC?5h z#26ARiFWA4n+3|cR-(QD=I=`uM*pqBNbsK97q*OxIFP|Q+QFs)LxB-bVTGj$0g7r- zet8_wP}j*qOglZMSG&QzO~I&*P&#Gl=cEKaDp6Y14Et%S2&fWtvjqGvtKh zectt~nRgD&GVN9uGq2OxC z$4vl;`dp;;dyTu*0fTK@Y?7Kyx!Sw-M;6BzZ5L!aZOWffoB(XA+j3sbEZ9cQuZ5n1 z-EU_CZ(j}|V0LeCTlbTko5<5oIXFeG?Tgkj+U)qb#gF+#o69>FAF(jU01O4lQ|7{x zAly?r4x%dampd+tPTS_4R$NFDdX0yBs7#kZHK|_KHwof zOhyf}Bk1((gq*2~3#}G+g{1wso_eI{RSR2S0WFJ#9i_Nj>9===S%iMWY$%VfH$=a< zTPrS)q04~8NS^t4RLQNc12b?K7kh1Yen8;Bj7e)Sx6kJFQuNhQPBL^6aA|w<%aw-GUmw+I{`8gwhCB(4NeO*KaE<;0m&NBy~x4K<~GiisS7D zZUBuq9C79)VKD9F>TVuTez|;5z*a}w4qrED9W8eX)YEUNnzmJ_ff~&pHw&YSDesBW_%Vr zJ;Y8pam>p@-DPKcQ(AN9Og{Cz_ko8*6y$*w-4BzxEPiWUT1~T z3mxBp;bXZb(k_DGmg65*l=Iw`7eVC5@czmN-saqNHzKgVtU@oNMh1$P98BvNXkE$5 zApu`4=3bZpx9mT-#;i4v4(eJ>^ zqOrL?LPVWu#104t;HsD*jSj=|h`MTixBt9JW*z3XqRkfE{Q-?&TTI6RhG94%uP?~f zLDbXshQ~@pvXRT24=OrPOU6KtI(O4{80X&ismt_8@VhsnzAbHY0lCn^z|7$fclKtX zVTy`7Hx{eytT_1CGedllop}zCwla^Pj{rA@Z9U&!e7GCdc~dVRjxqYfkBjH6*)nzJ z)lD%jrHH%ivFa?$iId>_bjRc^pk+blSE`5K&IQvpZNZ^BIxUrE$6-8#C=Q)~&o6xN zgo}<0oB7h`K_NCA7WDcS-`t?cJZK9^tJCF+8$2=E)&0m#v=Pt0B_rJt2A(>v;=Q+7 z&={jms6;726Sv-#H5RcX8_fkM$A*P2DJFZNN2x&a60XHFv6V~f6lHYUHYc=2T`2^RxK?E?-anl9NBb;F?>iWtc}O9X+SgA!;?{ z@+YMkGOvF|wO2}g-Br0yI9nQL15D>hi|X@*aVnwRypdJKRq!KJW{7}Js=u*SNmBMj z@w+OYiBkcUMwJnt7L(Z+7EQ5ubviE*99D-;Ys8^>lBp;@N9|Z@BymwHGAW9hf}Ce9 zfDxu$p-xbcwHT@yBis7P%1&^#%0^!cyZ8j2co;(d}*dIh6HcGR)T?1c#;H4|Nki-Xd-Mk)Q>3bqNU5wPg{o|TlTq828JReDGQFfRk=(+?N%ZOvS)l^bn^z$y-uGdRpSVvrMiwM+y6ljp(}Rp{h?ZFPPMoQfc?P zfrE@LE5hBGsKgy_?e-%>&8YFQpWUYYZFe8Wafm)Xsab9|fw7Ev9ccA*64|~UwJ&BR z2J0n1G|61DblbU9z1<|f=?;wQmSPQgEb)|6N$jJB)jME#3n=RkPPo&>4=4Bmn|7q_ z*dY?}89as6oE)$My4Ms6n*-u4eoL569GK>jV zUF2~P?zTk63E*~t#01^EF|ZTKK-<(r2c^nb%RZ90tBB8Uf~zV@(&FyJT7!ES3n#gYpmgTw$SI5Iyiu;`eZoq^xyaoN#;!bXS1dIJmg9B1M-K;BZfM~t zrorxFYPsDm8o0)h(dY^~Jsaytv9U^FO#$@ygxw3o%iYaeL~iJc9}&lu`;N?5buFba zhBWaQ;sUolrfDVxPi&oyvO%Fw>zK#Dg9M_Nf^M`rc*u#Ah*vZSONIAHp_N*f!J9-W z`>QlY%&(isCBJ600;#Wz*m0ntvR?4DqZk>7+a0Nf`oYdAMpD9G$M{@lD(Y@2mUYq= z{oaZjO^h3bXtZb&Io}KD=58^BUWM*Mx@6SnA8L(Rr$u=fm`@FGK4`?ka zmkZt$gH}W_!jiY0n-}80)`^*79&<@2;bx`gX7bZK&S>7kku4VOgfEv&j25r`J06lp@1#7$QP zNV30j;E)UDJN*Qu;yHTunQB30ODv0d86Fj2aoD`Cs_PPM;3H*_@cTyR#Uw~V()H#Y zh71IbD0p<9(-F>e0l<$VixaE6be>|H2F2HZtZZnjCVBS+u_PCwl$jESZ3nFQI| zi*b%*N~nfy@nkqlr}Y+B#GHK?!g$?b>BG-G`f$N17WN$paH?D21E)^xZV%lw*ihm$ zy#rIy#WYNI(e7#bdV66P<^Oc;!XP(p9%tkJ6*?@ed#A&jjjL)GJZ*m+A@|Hz$_xWG zw5L*Hy58~!d^OanZkX%$UItmafBsT%65&c(!j{^KTOCQc({=+AH3_N#ZqH5s8gqll z-E6?Bl`SE+>2xulL9`VL4d><6oGgQv+1H?0bvdWL-UtC-IZyv#SwbYDy_mNXD3Oy5 zOHjs5vN>t;to&n{KcE17J1}9!isIW8%yJqHaMc==q4V_33=;2HHe!x792qIqR|VE4 zySClT34I{eurF1MUYq0gxEO4V_c}@4U7b22%BDq`UG9-h%lia;^XjYCB$TgJQb{}z zj-Y_UCWlRO3BoiF-im?})9brQp=%cejS07g{$Yb+zP=Dms$y^=A2f`}m}R)2 z?fz#$^sY9puy3yRkRIpCbduD!wK__>!lJ^RX2jVxvI~q4WW!{opRJg^Aqw0aTbhdU z2loGV)$nCuH0BBw+dVoQ2mYrGXTd!5N24;kxnzyUyFtEo!NiZ9i4z??+}w@%gjO(SNTIz0lOOhxvlUmjfM}RFw#b#@=F8 z4U@u|6r9rpiw++Z%Y#;wlajPD$@F1do2|NvhxsR zz)EQMGtn5nj$_Qkv=W5F#MY=p2Jb*fh_imu4DXrulhG9?`vxbg)!$0xIOqpZ$D^)T zG=3-FqT`yEmmAaGp;y#~yw0{$XVM`z&IK*8x8;7f@4x2dQ|WAiw#2w6(@;eylH0iC z@&GsUaA=;b;;52GZ&T@_!%iH zAygHCT?O4;H$bXR`_e_hiIQ|Rof{%XSoCf#rx)W}GrOCsG>Uax2!iv|!FExtMV*w) zI5=m|TZyHCdw`^%{IQrYhk;H+jrt_pkO1dI8IyC>AgDiLFRpUg+lp=_8IU_?-HCW_ z;2`3r@61X;sgT-f55m?I&tzI^oOn%MvbYi*BY>Qt9+^>}-bV;Q(z`Blb~q8B>TA2(y%-`Y~8eRRWxC7x8tfY$^8h_ ztgX95GNxmNh0y_F!KogD*IQqW3!H5OJ)y9Mq|<@KqFerp+<{u0Vw?5kwvLkCfR4&x z6C9%7gx5`HMw2=;-=j;ipmrucG_*Vls1eaGf2=SuoP@w7a_|Minyp~i6r+S_ABYvj zG+oFd`hL)HFhbajT+V0Y@NN7q?c@1l@ibcr$An_Mdml=8TPWYfNXfi~cp~p0LdvEY z1fPH>h~JS7ZQcTD01NGRJ`SI^xjf~?4drPNXjC|7jQ7eqP> znYw+B2?*H+wWpEY4|9}wE8v!?hqs4l)iH`hb@rePDQ3`}OxFxK`V9!`k$P&>apu?iF zpraINk#A8$LDR6D8{S$fL`us1Alglxkkgac5&2#9MjUb)6usDgXB$dm;L*PB|Bu!Vt)>L@)~CLy8IXO_5Yq^8l~~R$np_H4V)4bTm4YCLNiM1O#X+ zD*E1mbrz2d(S(NDX08_{6_Z1`WfDCf$);tQT$7v40B(Hwr?4=Mij8HJu;nthIP>mt z8{yG;>fXs+!D0 zMFJ%Z9wdL_RPTN{h_u*`7GR3+Wjf)L&$^_Gl{sf1;tyEdu@YFBQphp41R-qgpf3)} zW32^ni*A_q&>hjP1uvCW=$ZQ&NSJ|SECBc3=qnmPL8CP3n4EUw=^A0&{*IGWXQ33Z zix6Wo&H)b2JJOJAlJ=gFgd$GCZ?O=2m)>DK8%7Ol??Iy?!hLd>EqYF{60mCZtg&4? zT=K@duU*$We(OEo#}#hNo||ybv%U75^hCoF>9PR zV)g2K!yZNLVbWp?1rUy75r^^Fq&OxAs$;A0oE69U1nz;cS=J!!xrscf?d9vP z#4?;O`?q$dvK7*MOhk$KnQ#vvk~y5A+g3PfFxCe;`&G_S1QNW0kyjxRk?5;xB+yg* z3@R;lHqrvt#Y#e9m?l9`+|`<>Cdft`X7agaH!dA54QR-q)>0M5`};sRE{i_mp+z@5htdu;%J!I@tQZmG?H*Q-vNZ9 zqTb!x0r0`t5AEx++1-H<3ZH&g`7U%rRwmQDj0L=iv)v)pF|)|=ZXN;z00MT=AGyS- zxxD|9=>@E+qi}b3yI^bQxEUt7}m5)Bd>+*oB2d)S(SG=nU-&9LP zFi0-~sp^4_>8;+6!A<;GNPlc^w;^S;Oq6fCD+rIwS(@FlFutfJmnG{!C92qUb>M6{ zkiZrK!F9}*3uv|vZ?x~fT(j|kUuo95OtWj5nVuzcJ&vm}Z0~I6IlS5^ad+vk2v1sXSB6!I7eHB-+(Cng+rSB!ihvz8ZZi^yp4T+CH|;n$}zJDo;xJ{WUb zu4a$}0{D7_iUwOxu3*B}V**sp8JGwuJ>3}!nLP}{H2)wLWFIC&G121Ro_2c&q7tNM z*-T6q;@^;6EEBeZT67U8Dc7S^MT)mcs?@_}=E03nr64qOE zPgkp3(LJ@r4s2tPzHC^yNILC?W>GAO9_gUHbSiqdduU_a#&32Awq_5(_Ny(rrPIf*+Sc!m%L=;p2?G*2?TuLHy~T0EKf^c-5_HjAf;oL{}*^u|D@qYyKaU1`63Jh66? zLGQ7!wy69pg&|!|vFI0^LbK!}pYcl0c!kcYpRH0DFGzxiXeKNKCG&S~s=O9E7R@NR zvodi%w;&2H(V+=$Pj)`J^O^M)vvG+qh*GLYVRFJ+i>0`__Vf#C`%SQYZkCN48!e>9 zM5JbGI;z`ZUToMu>HH_rEyO9K-Tsi78w78U%5bsTis%95kix0On-TC9j^6}5IT<1D z&M_4|=Rh8z-~>r@M7K*SU;^jWb-C?#%u$v9YeRy5l2=7o#y3DA*v?<)cF#-};8!G5 zE?L0-%9oCl0QoT86EXAZb&Cy!XLNX7?OWZNd*L*Zd%=WumM`C|-8jyfD|6r$I`o@_ zUaaZQA9QK4<)QnArOJc!>)A!#@}7UGk;5wG(*KoHICRIDm=As5!c}ksJ)f1r4EUa7 zQS&tQ!BFHYIQ=tHZ%B=4HFXUi+7sM8caGsF$lA8w!`Bd|TZkryc^F+B&CkK?nnQf` z#@=M-BcfelHPe!gw{hI4q=1ofm83hoFIR%yb$X9&{vMz*K%W;C`0dO1Zd`w7S9=G2 ztJe?R`?N7*Sak;zlo`!_vG3wJN8Q~ zes%CO0^SdHM7tlvJg!84Hn-Ru1}az=`ZX_aCk2PK6U=g*kAsd5^`M6<`>@jz@#FJ8 zZIb}II%=KtU(O$*Cf02>9Nx_v#J(ZL#zHKr2DZ}ft&G4JGg^2pSP@s_NN0(2M!T<2Ba^AWxuTLP7tCw1>V%iSsFjjfM#U-Je$&nw-t3vJCnIqPzU0n4_z+4`*vp zLQM8%bIwI1-5RjbHQU()=T4rKT208CkK#y$?ryeWc@Jhm8w*&*y&qQ)cwZPrikFHj zIBx{_-IE_!2K>NsZ+>9u`GMuV_yNg>(qd0ZMfxNueN5$-0fprnP$^2EBJbx7!oExp z_L!)*57{8>?}++4qW=F9QEw3Rdl2)VOmSgBm>O3a zjp7M{d{^T5zBQ*L0ow02d21S+>2OiE=S7^hTEZUYI!mJ2D-&|6P$q_CN|$sMw$^^w zC*|;d30iuRj3v$p{oq6;V>t<|6WyexVSHFnJ{J7PU9uCnWE6LvRb+k$2=N{|*~HXP z7yfKZhLxI&rr{)a0?9F#dgVw172Er-Q3hIuFU8*2}(FLN%ufIb0vxE%Ir% znR=zE4Gi3gVtWxpN2(Yx$RXAUuSGI= zC9vg%Yj3PHjOGdgj_mp{r8M{vbopPu%GkPFiQ|S&Y(PrtIyUO^tO#*p@~g8#{1u=r z(rH+sG zl9GJ>XUU3Zg5sfZiGcCJnGeP-w0~OeV5)1HPUbLh!f7VdZ6c0CnWQDG&4cBuSr)F7 z6G^l>=Hy#al`DWUQLvNCJmkF$(uw8F&Vmu*YVzvt<_0QJ*=fC`(rm5e=q+Ll>W&n; z1Y%OvqiQ1Bt)S$tkX$j!$1>u=YSsnamPHj39&_Hl5q}741IDXL z)qxCRh_UY17v0d%7=M* zSYPD4qPIgQ5P{L*QJ_KzyMh+gJ<4xUA&OW_8&V2;B{tcjoJ_FlZtHD7j`Gw?^+8J$ zeSMoKPe`Nc>>!HgN8a+@wN*q&IYCIPzDCp+n%cc4~i1@u7 z)lT#C1D_AhY^~YhOuTzmHtouGn(!>4NI)X*f2i!l{kvfYXBlg|KD^sJKCh0KG+6iG z#LZ9U#GOLd>`>^^q@ss@KEOnq6cbH_yS94=QX(D9hqvR@5>^F$XikHXI<#0V;3jXj z)+q8i6D~yJia{$nVTzwaBup!j$Wlat6NDCX$pw@$^~8+8WEL?3TyWt3Z+;-Aca+P- zr|P9zGEK^t?vd09*k`O(Y3+4iN*DyY{$gxgH^kY%E@Htmdny*KEsU!+cnr@GA5{;NyauwepJi{m0hKh`*l*8l;%by1>^BToXS3PDVeoxkm^OMN&voW zB00Dg0Be96=mV!QJnl~{cwOUvr#@mCt(Ydvgw?KzjuG{{@(q)`+N8I?(f~-yT5mT|ERzJ3u`nL;{dn;0AXiY At^fc4 diff --git a/submit/routes/__init__.py b/submit/routes/__init__.py deleted file mode 100644 index ef9b01b..0000000 --- a/submit/routes/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""arxiv ui-app routes.""" - -from .ui import UI \ No newline at end of file diff --git a/submit/routes/api/__init__.py b/submit/routes/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/submit/routes/auth.py b/submit/routes/auth.py deleted file mode 100644 index 3d79ceb..0000000 --- a/submit/routes/auth.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Authorization helpers for :mod:`submit` application.""" - -from arxiv_auth.domain import Session - -from flask import request -from werkzeug.exceptions import NotFound - -from arxiv.base import logging - -logger = logging.getLogger(__name__) -logger.propagate = False - - -# TODO: when we get to the point where we need to support delegations, this -# will need to be updated. -def is_owner(session: Session, submission_id: str, **kw) -> bool: - """Check whether the user has privileges to edit a ui-app.""" - if not request.submission: - logger.debug('No ui-app on request') - raise NotFound('No such ui-app') - logger.debug('Submission owned by %s; request is from %s', - str(request.submission.owner.native_id), - str(session.user.user_id)) - return str(request.submission.owner.native_id) == str(session.user.user_id) diff --git a/submit/routes/ui/__init__.py b/submit/routes/ui/__init__.py deleted file mode 100644 index 0273bb6..0000000 --- a/submit/routes/ui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .ui import UI \ No newline at end of file diff --git a/submit/routes/ui/flow_control.py b/submit/routes/ui/flow_control.py deleted file mode 100644 index 6ca0dee..0000000 --- a/submit/routes/ui/flow_control.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Handles which UI route to proceeded in ui-app workflows.""" - -from http import HTTPStatus as status -from functools import wraps -from typing import Optional, Callable, Union, Dict, Tuple -from typing_extensions import Literal - -from flask import request, redirect, url_for, session, make_response -from flask import Response as FResponse -from werkzeug import Response as WResponse -from werkzeug.exceptions import InternalServerError, BadRequest - -from arxiv.base import alerts, logging -from arxiv.submission.domain import Submission - -from submit.workflow import SubmissionWorkflow, ReplacementWorkflow -from submit.workflow.stages import Stage -from submit.workflow.processor import WorkflowProcessor - -from submit.controllers.ui.util import Response as CResponse -from submit.util import load_submission - - -logger = logging.getLogger(__name__) - -EXIT = 'ui.create_submission' - -PREVIOUS = 'previous' -NEXT = 'next' -SAVE_EXIT = 'save_exit' - - -FlowDecision = Literal['SHOW_CONTROLLER_RESULT', 'REDIRECT_EXIT', - 'REDIRECT_PREVIOUS', 'REDIRECT_NEXT', - 'REDIRECT_PARENT_STAGE', 'REDIRECT_CONFIRMATION'] - - -Response = Union[FResponse, WResponse] - -# might need RESHOW_FORM -FlowAction = Literal['prevous','next','save_exit'] -FlowResponse = Tuple[FlowAction, Response] - -ControllerDesires = Literal['stage_success', 'stage_reshow', 'stage_current', 'stage_parent'] -STAGE_SUCCESS: ControllerDesires = 'stage_success' -STAGE_RESHOW: ControllerDesires = 'stage_reshow' -STAGE_CURRENT: ControllerDesires = 'stage_current' -STAGE_PARENT: ControllerDesires = 'stage_parent' - -def ready_for_next(response: CResponse) -> CResponse: - """Mark the result from a controller being ready to move to the - next stage""" - response[0].update({'flow_control_from_controller': STAGE_SUCCESS}) - return response - - -def stay_on_this_stage(response: CResponse) -> CResponse: - """Mark the result from a controller as should return to the same stage.""" - response[0].update({'flow_control_from_controller': STAGE_RESHOW}) - return response - - -def advance_to_current(response: CResponse) -> CResponse: - """Mark the result from a controller as should return to the same stage.""" - response[0].update({'flow_control_from_controller': STAGE_CURRENT}) - return response - - -def return_to_parent_stage(response: CResponse) -> CResponse: - """Mark the result from a controller as should return to the parent stage. - Such as delete_file to the FileUpload stage.""" - response[0].update({'flow_control_from_controller': STAGE_PARENT}) - return response - - -def get_controllers_desire(data: Dict) -> Optional[ControllerDesires]: - return data.get('flow_control_from_controller', None) - - -def endpoint_name() -> Optional[str]: - """Get workflow compatable endpoint name from request""" - if request.url_rule is None: - return None - endpoint = request.url_rule.endpoint - if '.' in endpoint: - _, endpoint = endpoint.split('.', 1) - return str(endpoint) - - -def get_seen() -> Dict[str, bool]: - """Get seen steps from user session.""" - # TODO Fix seen to handle mutlipe submissions at the same time - return session.get('steps_seen', {}) # type: ignore We know this is a dict. - - -def put_seen(seen: Dict[str, bool]) -> None: - """Put the seen steps into the users session.""" - # TODO Fix seen to handle mutlipe submissions at the same time - session['steps_seen'] = seen - - -def get_workflow(submission: Optional[Submission]) -> WorkflowProcessor: - """Guesses the workflow based on the ui-app and its version.""" - if submission is not None and submission.version > 1: - return WorkflowProcessor(ReplacementWorkflow, submission, get_seen()) - return WorkflowProcessor(SubmissionWorkflow, submission, get_seen()) - - -def to_stage(stage: Optional[Stage], ident: str) -> Response: - """Return a flask redirect to Stage.""" - if stage is None: - return redirect(url_for('ui.create_submission'), code=status.SEE_OTHER) - loc = url_for(f'ui.{stage.endpoint}', submission_id=ident) - return redirect(loc, code=status.SEE_OTHER) - - -def to_previous(wfs: WorkflowProcessor, stage: Stage, ident: str) -> Response: - """Return a flask redirect to the previous stage.""" - return to_stage(wfs.workflow.previous_stage(stage), ident) - - -def to_next(wfs: WorkflowProcessor, stage: Stage, ident: str) -> Response: - """Return a flask redirect to the next stage.""" - if stage is None: - return to_current(wfs, ident) - else: - return to_stage(wfs.next_stage(stage), ident) - - -def to_current(wfs: WorkflowProcessor, ident: str, flash: bool = True)\ - -> Response: - """Return a flask redirect to the stage required by the workflow.""" - next_stage = wfs.current_stage() - if flash and next_stage is not None: - alerts.flash_warning(f'Please {next_stage.label} before proceeding.') - return to_stage(next_stage, ident) - - -# TODO QUESTION: Can we just wrap the controller and not -# do the decorate flask route to wrap the controller? -# Answer: Not sure, @wraps saves the function name which might -# be done for debugging. - - -def flow_control(blueprint_this_stage: Optional[Stage] = None, - exit: str = EXIT) -> Callable: - """Get a blueprint route decorator that wraps a controller to - handle redirection to next/previous steps. - - - Parameters - ---------- - - blueprint_this_stage : - The mapping of the Stage to blueprint is in the Stage. So, - usually, this will be None and the stage will be determined from - the context by checking the ui-app and route. If passed, this - will be used as the stage for controlling the flow of the wrapped - controller. This will allow a auxilrary route to be used with a - stage, ex delete_all_files route for the stage FileUpload. - - exit: - Route to redirect to when user selectes exit action. - - """ - def route(controller: Callable) -> Callable: - """Decorate blueprint route so that it wrapps the controller with - workflow redirection.""" - # controler gets 'updated' to look like wrapper but keeps - # name and docstr - # https://docs.python.org/2/library/functools.html#functools.wraps - @wraps(controller) - def wrapper(submission_id: str) -> Response: - """Update the redirect to the next, previous, or exit page.""" - - action = request.form.get('action', None) - submission, _ = load_submission(submission_id) - workflow = request.workflow - this_stage = blueprint_this_stage or \ - workflow.workflow[endpoint_name()] - - # convert classes, ints and strs to actual instances - this_stage = workflow.workflow[this_stage] - - if workflow.is_complete() and not endpoint_name() == workflow.workflow.confirmation.endpoint: - return to_stage(workflow.workflow.confirmation, submission_id) - - if not workflow.can_proceed_to(this_stage): - logger.debug(f'sub {submission_id} cannot proceed to {this_stage}') - return to_current(workflow, submission_id) - - # If the user selects "go back", we attempt to save their input - # above. But if the input does not validate, we don't prevent them - # from going to the previous step. - try: - data, code, headers, resp_fn = controller(submission_id) - #WARNING: controllers do not update the ui-app in this scope - except BadRequest: - if action == PREVIOUS: - return to_previous(workflow, this_stage, submission_id) - raise - - workflow.mark_seen(this_stage) - put_seen(workflow.seen) - - last_stage = workflow.workflow.order[-1] == this_stage - controller_action = get_controllers_desire(data) - flow_desc = flow_decision(request.method, action, code, - controller_action, last_stage) - logger.debug(f'method: {request.method} action: {action}, code: {code}, ' - f'controller action: {controller_action}, last_stage: {last_stage}') - logger.debug(f'flow decisions is {flow_desc}') - - if flow_desc == 'REDIRECT_CONFIRMATION': - return to_stage(workflow.workflow.confirmation, submission_id) - if flow_desc == 'SHOW_CONTROLLER_RESULT': - return resp_fn() - if flow_desc == 'REDIRECT_EXIT': - return redirect(url_for(exit), code=status.SEE_OTHER) - if flow_desc == 'REDIRECT_NEXT': - return to_next(workflow, this_stage, submission_id) - if flow_desc == 'REDIRECT_PREVIOUS': - return to_previous(workflow, this_stage, submission_id) - if flow_desc == 'REDIRECT_PARENT_STAGE': - return to_stage(this_stage, submission_id) - else: - raise ValueError(f'flow_desc must be of type FlowDecision but was {flow_desc}') - - return wrapper - return route - - -def flow_decision(method: str, - user_action: Optional[str], - code: int, - controller_action: Optional[ControllerDesires], - last_stage: bool)\ - -> FlowDecision: - # For now with GET we do the same sort of things - if method == 'GET' and controller_action == STAGE_CURRENT: - return 'REDIRECT_NEXT' - if (method == 'GET' and code == 200) or \ - (method == 'GET' and controller_action == STAGE_RESHOW): - return 'SHOW_CONTROLLER_RESULT' - if method == 'GET' and code != status.OK: - return 'SHOW_CONTROLLER_RESULT' # some sort of error? - - if method != 'POST': - return 'SHOW_CONTROLLER_RESULT' # Not sure, HEAD? PUT? - - # after this point method must be POST - if controller_action == STAGE_SUCCESS: - if last_stage: - return 'REDIRECT_CONFIRMATION' - if user_action == NEXT: - return 'REDIRECT_NEXT' - if user_action == SAVE_EXIT: - return 'REDIRECT_EXIT' - if user_action == PREVIOUS: - return 'REDIRECT_PREVIOUS' - if user_action is None: # like cross_list with action ADD? - return 'SHOW_CONTROLLER_RESULT' - - if controller_action == STAGE_RESHOW: - if user_action == NEXT: - # Reshow the form to the due to form errors - return 'SHOW_CONTROLLER_RESULT' - if user_action == PREVIOUS: - # User wants to go back but there are errors on the form - # We ignore the errors and go back. The user's form input - # is probably lost. - return 'REDIRECT_PREVIOUS' - if user_action == SAVE_EXIT: - return 'REDIRECT_EXIT' - - if controller_action == STAGE_PARENT: - # This is what we get from a sub-form like upload_delete.delete_file - # on success. Redirect to parent stage. - return 'REDIRECT_PARENT_STAGE' - - # These are the same as if the controller_action was STAGE_SUCCESS - # Not sure if that is a good thing or a bad thing. - if user_action == NEXT: - return 'REDIRECT_NEXT' - if user_action == SAVE_EXIT: - return 'REDIRECT_EXIT' - if user_action == PREVIOUS: - return 'REDIRECT_PREVIOUS' - # default to what? - return 'SHOW_CONTROLLER_RESULT' diff --git a/submit/routes/ui/ui.py b/submit/routes/ui/ui.py deleted file mode 100644 index 7eb69e1..0000000 --- a/submit/routes/ui/ui.py +++ /dev/null @@ -1,558 +0,0 @@ -"""Provides routes for the ui-app user interface.""" - -from http import HTTPStatus as status -from typing import Optional, Callable, Dict, List, Union, Any - -from arxiv_auth import auth -from flask import Blueprint, make_response, redirect, request, Markup, \ - render_template, url_for, g, send_file, session -from flask import Response as FResponse -from werkzeug.datastructures import MultiDict -from werkzeug import Response as WResponse -from werkzeug.exceptions import InternalServerError, BadRequest, \ - ServiceUnavailable - -import arxiv.submission as events -from arxiv import taxonomy -from arxiv.base import logging, alerts -from arxiv.submission.domain import Submission -from arxiv.submission.services.classic.exceptions import Unavailable - -from ..auth import is_owner -from submit import util -from submit.controllers import ui as cntrls -from submit.controllers.ui.new import upload -from submit.controllers.ui.new import upload_delete - -from submit.workflow.stages import FileUpload - -from submit.workflow import SubmissionWorkflow, ReplacementWorkflow, Stage -from submit.workflow.processor import WorkflowProcessor - -from .flow_control import flow_control, get_workflow, endpoint_name - -logger = logging.getLogger(__name__) - -UI = Blueprint('ui', __name__, url_prefix='/') - -SUPPORT = Markup( - 'If you continue to experience problems, please contact' - ' arXiv support.' -) - -Response = Union[FResponse, WResponse] - - -def redirect_to_login(*args, **kwargs) -> str: - """Send the unauthorized user to the log in page.""" - return redirect(url_for('login')) - - -@UI.before_request -def load_submission() -> None: - """Load the ui-app before the request is processed.""" - if request.view_args is None or 'submission_id' not in request.view_args: - return - submission_id = request.view_args['submission_id'] - try: - request.submission, request.events = \ - util.load_submission(submission_id) - except Unavailable as e: - raise ServiceUnavailable('Could not connect to database') from e - - wfp = get_workflow(request.submission) - request.workflow = wfp - request.current_stage = wfp.current_stage() - request.this_stage = wfp.workflow[endpoint_name()] - - -@UI.context_processor -def inject_workflow() -> Dict[str, Optional[WorkflowProcessor]]: - """Inject the current workflow into the template rendering context.""" - rd = {} - if hasattr(request, 'workflow'): - rd['workflow'] = request.workflow - if hasattr(request, 'current_stage'): - rd['get_current_stage_for_submission'] = request.current_stage - if hasattr(request, 'this_stage'): - rd['this_stage'] = request.this_stage - return rd - - # TODO below is unexpected: why are we setting this to a function? - return {'workflow': None, 'get_workflow': get_workflow} - - -def add_immediate_alert(context: dict, severity: str, - message: Union[str, dict], title: Optional[str] = None, - dismissable: bool = True, safe: bool = False) -> None: - """Add an alert for immediate display.""" - if safe and isinstance(message, str): - message = Markup(message) - data = {'message': message, 'title': title, 'dismissable': dismissable} - - if 'immediate_alerts' not in context: - context['immediate_alerts'] = [] - context['immediate_alerts'].append((severity, data)) - - -def handle(controller: Callable, template: str, title: str, - submission_id: Optional[int] = None, - get_params: bool = False, flow_controlled: bool = False, - **kwargs: Any) -> Response: - """ - Generalized request handling pattern. - - Parameters - ---------- - controller : callable - A controller function with the signature ``(method: str, params: - MultiDict, session: Session, submission_id: int, token: str) -> - Tuple[dict, int, dict]`` - template : str - HTML template to use in the response. - title : str - Page title, if not provided by controller. - submission_id : int or None - get_params : bool - If True, GET parameters will be passed to the controller on GET - requests. Default is False. - kwargs : kwargs - Passed as ``**kwargs`` to the controller. - - Returns - ------- - :class:`.Response` - - """ - response: Response - logger.debug('Handle call to controller %s with template %s, title %s,' - ' and ID %s', controller, template, title, submission_id) - if request.method == 'GET' and get_params: - request_data = MultiDict(request.args.items(multi=True)) - else: - request_data = MultiDict(request.form.items(multi=True)) - - context = {'pagetitle': title} - - data, code, headers = controller(request.method, request_data, - request.auth, submission_id, - **kwargs) - context.update(data) - - if flow_controlled: - return (data, code, headers, - lambda: make_response(render_template(template, **context), code)) - if code < 300: - response = make_response(render_template(template, **context), code) - elif 'Location' in headers: - response = redirect(headers['Location'], code=code) - else: - response = FResponse(response=context, status=code, headers=headers) - return response - - -@UI.route('/status', methods=['GET']) -def service_status(): - """Status endpoint.""" - return 'ok' - - -@UI.route('/', methods=["GET"]) -@auth.decorators.scoped(auth.scopes.CREATE_SUBMISSION, - unauthorized=redirect_to_login) -def manage_submissions(): - """Display the ui-app management dashboard.""" - return handle(cntrls.create, 'submit/manage_submissions.html', - 'Manage submissions') - - -@UI.route('/', methods=["POST"]) -@auth.decorators.scoped(auth.scopes.CREATE_SUBMISSION, - unauthorized=redirect_to_login) -def create_submission(): - """Create a new ui-app.""" - return handle(cntrls.create, 'submit/manage_submissions.html', - 'Create a new ui-app') - - -@UI.route('//unsubmit', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def unsubmit_submission(submission_id: int): - """Unsubmit (unfinalize) a ui-app.""" - return handle(cntrls.new.unsubmit.unsubmit, - 'submit/confirm_unsubmit.html', - 'Unsubmit ui-app', submission_id) - - -@UI.route('//delete', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.DELETE_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def delete_submission(submission_id: int): - """Delete, or roll a ui-app back to the last announced state.""" - return handle(cntrls.delete.delete, - 'submit/confirm_delete_submission.html', - 'Delete ui-app or replacement', submission_id) - - -@UI.route('//cancel/', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def cancel_request(submission_id: int, request_id: str): - """Cancel a pending request.""" - return handle(cntrls.delete.cancel_request, - 'submit/confirm_cancel_request.html', 'Cancel request', - submission_id, request_id=request_id) - - -@UI.route('//replace', methods=["POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def create_replacement(submission_id: int): - """Create a replacement ui-app.""" - return handle(cntrls.new.create.replace, 'submit/replace.html', - 'Create a new version (replacement)', submission_id) - - -@UI.route('/', methods=["GET"]) -@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def submission_status(submission_id: int) -> Response: - """Display the current state of the ui-app.""" - return handle(cntrls.submission_status, 'submit/status.html', - 'Submission status', submission_id) - - -@UI.route('//edit', methods=['GET']) -@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def submission_edit(submission_id: int) -> Response: - """Redirects to current edit stage of the ui-app.""" - return handle(cntrls.submission_edit, 'submit/status.html', - 'Submission status', submission_id, flow_controlled=True) - -# # TODO: remove me!! -# @UI.route(path('announce'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def announce(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.announce_submission(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) -# -# -# # TODO: remove me!! -# @UI.route(path('/place_on_hold'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def place_on_hold(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.place_on_hold(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) -# -# -# # TODO: remove me!! -# @UI.route(path('apply_cross'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def apply_cross(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.apply_cross(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) -# -# -# # TODO: remove me!! -# @UI.route(path('reject_cross'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def reject_cross(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.reject_cross(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) -# -# -# # TODO: remove me!! -# @UI.route(path('apply_withdrawal'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def apply_withdrawal(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.apply_withdrawal(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) -# -# -# # TODO: remove me!! -# @UI.route(path('reject_withdrawal'), methods=["GET"]) -# @auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner) -# def reject_withdrawal(submission_id: int) -> Response: -# """WARNING WARNING WARNING this is for testing purposes only.""" -# util.reject_withdrawal(submission_id) -# target = url_for('ui.submission_status', submission_id=submission_id) -# return Response(response={}, status=status.SEE_OTHER, -# headers={'Location': target}) - - -@UI.route('//verify_user', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def verify_user(submission_id: Optional[int] = None) -> Response: - """Render the submit start page.""" - return handle(cntrls.verify, 'submit/verify_user.html', - 'Verify User Information', submission_id, flow_controlled=True) - - -@UI.route('//authorship', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def authorship(submission_id: int) -> Response: - """Render step 2, authorship.""" - return handle(cntrls.authorship, 'submit/authorship.html', - 'Confirm Authorship', submission_id, flow_controlled=True) - - -@UI.route('//license', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def license(submission_id: int) -> Response: - """Render step 3, select license.""" - return handle(cntrls.license, 'submit/license.html', - 'Select a License', submission_id, flow_controlled=True) - - -@UI.route('//policy', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def policy(submission_id: int) -> Response: - """Render step 4, policy agreement.""" - return handle(cntrls.policy, 'submit/policy.html', - 'Acknowledge Policy Statement', submission_id, - flow_controlled=True) - - -@UI.route('//classification', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def classification(submission_id: int) -> Response: - """Render step 5, choose classification.""" - return handle(cntrls.classification, - 'submit/classification.html', - 'Choose a Primary Classification', submission_id, - flow_controlled=True) - - -@UI.route('//cross_list', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def cross_list(submission_id: int) -> Response: - """Render step 6, secondary classes.""" - return handle(cntrls.cross_list, - 'submit/cross_list.html', - 'Choose Cross-List Classifications', submission_id, - flow_controlled=True) - - -@UI.route('//file_upload', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def file_upload(submission_id: int) -> Response: - """Render step 7, file upload.""" - return handle(upload.upload_files, 'submit/file_upload.html', - 'Upload Files', submission_id, files=request.files, - token=request.environ['token'], flow_controlled=True) - - -@UI.route('//file_delete', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control(FileUpload) -def file_delete(submission_id: int) -> Response: - """Provide the file deletion endpoint, part of the upload step.""" - return handle(upload_delete.delete_file, 'submit/confirm_delete.html', - 'Delete File', submission_id, get_params=True, - token=request.environ['token'], flow_controlled=True) - - -@UI.route('//file_delete_all', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control(FileUpload) -def file_delete_all(submission_id: int) -> Response: - """Provide endpoint to delete all files, part of the upload step.""" - return handle(upload_delete.delete_all, - 'submit/confirm_delete_all.html', 'Delete All Files', - submission_id, get_params=True, - token=request.environ['token'], flow_controlled=True) - - -@UI.route('//file_process', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def file_process(submission_id: int) -> Response: - """Render step 8, file processing.""" - return handle(cntrls.process.file_process, 'submit/file_process.html', - 'Process Files', submission_id, get_params=True, - token=request.environ['token'], flow_controlled=True) - - -@UI.route('//preview.pdf', methods=["GET"]) -@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -# TODO @flow_control(Process)? -def file_preview(submission_id: int) -> Response: - data, code, headers = cntrls.new.process.file_preview( - MultiDict(request.args.items(multi=True)), - request.auth, - submission_id, - request.environ['token'] - ) - rv = send_file(data, mimetype=headers['Content-Type'], cache_timeout=0) - rv.set_etag(headers['ETag']) - rv.headers['Content-Length'] = len(data) # type: ignore - rv.headers['Cache-Control'] = 'no-store' - return rv - - -@UI.route('//compilation_log', methods=["GET"]) -@auth.decorators.scoped(auth.scopes.VIEW_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -# TODO @flow_control(Process) ? -def compilation_log(submission_id: int) -> Response: - data, code, headers = cntrls.process.compilation_log( - MultiDict(request.args.items(multi=True)), - request.auth, - submission_id, - request.environ['token'] - ) - rv = send_file(data, mimetype=headers['Content-Type'], cache_timeout=0) - rv.set_etag(headers['ETag']) - rv.headers['Cache-Control'] = 'no-store' - return rv - - -@UI.route('//add_metadata', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def add_metadata(submission_id: int) -> Response: - """Render step 9, metadata.""" - return handle(cntrls.metadata, 'submit/add_metadata.html', - 'Add or Edit Metadata', submission_id, flow_controlled=True) - - -@UI.route('//add_optional_metadata', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def add_optional_metadata(submission_id: int) -> Response: - """Render step 9, metadata.""" - return handle(cntrls.optional, - 'submit/add_optional_metadata.html', - 'Add or Edit Metadata', submission_id, flow_controlled=True) - - -@UI.route('//final_preview', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def final_preview(submission_id: int) -> Response: - """Render step 10, preview.""" - return handle(cntrls.finalize, 'submit/final_preview.html', - 'Preview and Approve', submission_id, flow_controlled=True) - - -@UI.route('//confirmation', methods=['GET', 'POST']) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def confirmation(submission_id: int) -> Response: - """Render the final confirmation page.""" - return handle(cntrls.new.final.confirm, "submit/confirm_submit.html", - 'Submission Confirmed', - submission_id, flow_controlled=True) - -# Other workflows. - - -# Jref is a single controller and not a workflow -@UI.route('//jref', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def jref(submission_id: Optional[int] = None) -> Response: - """Render the JREF ui-app page.""" - return handle(cntrls.jref.jref, 'submit/jref.html', - 'Add journal reference', submission_id, - flow_controlled=False) - - -@UI.route('//withdraw', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -def withdraw(submission_id: Optional[int] = None) -> Response: - """Render the withdrawal request page.""" - return handle(cntrls.withdraw.request_withdrawal, - 'submit/withdraw.html', 'Request withdrawal', - submission_id, flow_controlled=False) - - -@UI.route('//request_cross', methods=["GET", "POST"]) -@auth.decorators.scoped(auth.scopes.EDIT_SUBMISSION, authorizer=is_owner, - unauthorized=redirect_to_login) -@flow_control() -def request_cross(submission_id: Optional[int] = None) -> Response: - """Render the cross-list request page.""" - return handle(cntrls.cross.request_cross, - 'submit/request_cross_list.html', 'Request cross-list', - submission_id, flow_controlled=True) - -@UI.route('/testalerts') -def testalerts() -> Response: - tc = {} - request.submission, request.events = util.load_submission(1) - wfp = get_workflow(request.submission) - request.workflow = wfp - request.current_stage = wfp.current_stage() - request.this_stage = wfp.workflow[endpoint_name()] - - tc['workflow'] = wfp - tc['submission_id'] = 1 - - add_immediate_alert(tc, 'WARNING', 'This is a warning to you from the normal ui-app alert system.', "SUBMISSION ALERT TITLE") - alerts.flash_failure('This is one of those alerts from base alert(): you failed', 'BASE ALERT') - return make_response(render_template('submit/testalerts.html', **tc), 200) - - -@UI.app_template_filter() -def endorsetype(endorsements: List[str]) -> str: - """ - Transmit endorsement status to template for message filtering. - - Parameters - ---------- - endorsements : list - The list of categories (str IDs) for which the user is endorsed. - - Returns - ------- - str - For now. - - """ - if len(endorsements) == 0: - return 'None' - elif '*.*' in endorsements: - return 'All' - return 'Some' diff --git a/submit/services/__init__.py b/submit/services/__init__.py deleted file mode 100644 index 6c2968e..0000000 --- a/submit/services/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""External service integrations.""" - diff --git a/submit/static/css/manage_submissions.css b/submit/static/css/manage_submissions.css deleted file mode 100644 index b9c827e..0000000 --- a/submit/static/css/manage_submissions.css +++ /dev/null @@ -1,89 +0,0 @@ -@media only screen and (max-width: 320px) { - .box { - padding: unset; - padding-bottom: 0.75em; } } -@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { - table { - display: block; } - - thead { - display: block; } - thead tr { - position: absolute; - top: -9999px; - left: -9999px; } - - tbody { - display: block; } - - th { - display: block; } - - td { - display: block; - border: none; - position: relative; - padding-left: 30%; } - td:before { - position: absolute; - top: 6px; - left: 6px; - width: 45%; - padding-right: 10px; - white-space: nowrap; } - td.user-submission:nth-of-type(1):before { - content: "Status"; - font-weight: bold; } - td.user-submission:nth-of-type(2):before { - content: "Identifier"; - font-weight: bold; } - td.user-submission:nth-of-type(3):before { - content: "Title"; - font-weight: bold; } - td.user-submission:nth-of-type(4):before { - content: "Created"; - font-weight: bold; } - td.user-submission:nth-of-type(5):before { - content: "Actions"; - font-weight: bold; } - td.user-announced:nth-of-type(1):before { - content: "Identifier"; - font-weight: bold; } - td.user-announced:nth-of-type(2):before { - content: "Primary Classification"; - font-weight: bold; } - td.user-announced:nth-of-type(3):before { - content: "Title"; - font-weight: bold; } - td.user-announced:nth-of-type(4):before { - content: "Actions"; - font-weight: bold; } - - tr { - display: block; - border: 1px solid #ccc; } - - .table td { - padding-left: 30%; } - .table th { - padding-left: 30%; } } -.box-new-submission { - padding: 2rem important; } - -.button-create-submission { - margin-bottom: 1.5rem; } - -.button-delete-submission { - border: 0px; - text-decoration: none; } - -/* Welcome message -- "alpha" box */ -/* This is the subtitle text in the "alpha" box. */ -.subtitle-intro { - margin-left: 1.25em; } - -.alpha-before { - position: relative; - margin-left: 2em; } - -/*# sourceMappingURL=manage_submissions.css.map */ diff --git a/submit/static/css/manage_submissions.css.map b/submit/static/css/manage_submissions.css.map deleted file mode 100644 index 6986389..0000000 --- a/submit/static/css/manage_submissions.css.map +++ /dev/null @@ -1,7 +0,0 @@ -{ -"version": 3, -"mappings": "AAGA,yCAAyC;EACvC,IAAI;IACF,OAAO,EAAE,KAAK;IACd,cAAc,EAAE,MAAM;AAE1B,mGAAgG;EAC9F,KAAK;IACH,OAAO,EAAE,KAAK;;EAEhB,KAAK;IACH,OAAO,EAAE,KAAK;IACd,QAAE;MACA,QAAQ,EAAE,QAAQ;MAClB,GAAG,EAAE,OAAO;MACZ,IAAI,EAAE,OAAO;;EAGjB,KAAK;IACH,OAAO,EAAE,KAAK;;EAEhB,EAAE;IACA,OAAO,EAAE,KAAK;;EAEhB,EAAE;IACA,OAAO,EAAE,KAAK;IACd,MAAM,EAAE,IAAI;IACZ,QAAQ,EAAE,QAAQ;IAClB,YAAY,EAAE,GAAG;IACjB,SAAQ;MACN,QAAQ,EAAE,QAAQ;MAClB,GAAG,EAAE,GAAG;MACR,IAAI,EAAE,GAAG;MACT,KAAK,EAAE,GAAG;MACV,aAAa,EAAE,IAAI;MACnB,WAAW,EAAE,MAAM;IAGnB,wCAAuB;MACrB,OAAO,EAAE,QAAQ;MACjB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,YAAY;MACrB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,OAAO;MAChB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;IAEnB,wCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;IAInB,uCAAuB;MACrB,OAAO,EAAE,YAAY;MACrB,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,wBAAwB;MACjC,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,OAAO;MAChB,WAAW,EAAE,IAAI;IAEnB,uCAAuB;MACrB,OAAO,EAAE,SAAS;MAClB,WAAW,EAAE,IAAI;;EAIvB,EAAE;IACA,OAAO,EAAE,KAAK;IACd,MAAM,EAAE,cAA+B;;EAGvC,SAAE;IACA,YAAY,EAAE,GAAG;EAEnB,SAAE;IACA,YAAY,EAAE,GAAG;AAGvB,mBAAmB;EACjB,OAAO,EAAE,cAAc;;AAEzB,yBAAyB;EACvB,aAAa,EAAE,MAAM;;AAEvB,yBAAyB;EACvB,MAAM,EAAE,GAAG;EACX,eAAe,EAAE,IAAI;;;;AAMvB,eAAe;EACb,WAAW,EAAE,MAAM;;AAErB,aAAa;EACX,QAAQ,EAAE,QAAQ;EAClB,WAAW,EAAE,GAAG", -"sources": ["../sass/manage_submissions.sass"], -"names": [], -"file": "manage_submissions.css" -} \ No newline at end of file diff --git a/submit/static/css/submit.css b/submit/static/css/submit.css deleted file mode 100644 index 7ae6c88..0000000 --- a/submit/static/css/submit.css +++ /dev/null @@ -1,551 +0,0 @@ -@charset "UTF-8"; -/* Cuts down on unnecessary whitespace at top. Consider move to base. */ -main { - padding: 1rem 1.5rem; -} - -.section { - /* move this to base as $section-padding */ - padding: 1em 0; -} - -.button, p .button { - font-family: "Open Sans", "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 1rem; -} -.button.is-short, p .button.is-short { - height: 1.75em; -} -.button svg.icon, p .button svg.icon { - top: 0; - font-size: 0.9em; -} - -.button.reprocess { - margin-top: 0.5em; -} - -/* Allows text in a button to wrap responsively. */ -.button-is-wrappable { - height: 100%; - white-space: normal; -} - -.button-feedback { - vertical-align: baseline; -} - -.submit-nav { - margin-top: 1.75rem; - margin-bottom: 2em !important; - justify-content: flex-end; -} -.submit-nav .button .icon { - margin-right: 0 !important; -} - -/* controls display of close button for messages */ -.message button.notification-dismiss { - position: relative; - z-index: 1; - top: 30px; - margin-left: calc(100% - 30px); -} - -.form-margin { - margin: 0.4em 0; -} - -.notifications { - margin-bottom: 1em; -} - -.policy-scroll { - margin: 0; - margin-bottom: -1em; - padding: 1em 3em; - max-height: 400px; - overflow-y: scroll; -} -@media screen and (max-width: 768px) { - .policy-scroll { - padding: 1em; - } -} -@media screen and (max-width: 400px) { - .policy-scroll { - max-height: 250px; - } -} - -/* forces display of scrollbar in webkit browsers */ -.policy-scroll::-webkit-scrollbar { - -webkit-appearance: none; - width: 7px; -} - -.policy-scroll::-webkit-scrollbar-thumb { - border-radius: 4px; - background-color: rgba(0, 0, 0, 0.5); - -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); -} - -nav.submit-pagination { - display: block; - /*float: right*/ - width: 100%; -} - -h2 .title-submit { - margin-bottom: 0em; - margin-top: 0.25em !important; - display: block; - float: left; - width: 10%; - display: none; - color: #005e9d; -} -h2 .replacement { - margin-top: -2rem; - margin-bottom: 2rem; - font-style: italic; - font-weight: 400; -} - -h1.title.title-submit { - margin-top: 0.75em; - margin-bottom: 0.5em; - clear: both; - color: #005e9d !important; -} -h1.title.title-submit .preamble { - color: #7f8d93; -} -@media screen and (max-width: 768px) { - h1.title.title-submit .title.title-submit { - margin-top: 0; - } -} - -.texlive-summary article { - padding: 0.8rem; -} -.texlive-summary article:not(:last-child) { - margin-bottom: 0.5em; - border-bottom-width: 1px; - border-bottom-style: dotted; -} - -.alpha-before::before { - content: "α"; - color: rgba(204, 204, 204, 0.5); - font-size: 10em; - position: absolute; - top: -0.4em; - left: -0.25em; - line-height: 1; - font-family: serif; -} - -.beta-before::before { - content: "β"; - color: rgba(204, 204, 204, 0.5); - font-size: 10em; - position: absolute; - top: -0.4em; - left: -0.25em; - line-height: 1; - font-family: serif; -} - -/* overrides for this form only? may move to base */ -.field:not(:last-child) { - margin-bottom: 1.25em; -} - -.label:not(:last-child) { - margin-bottom: 0.25em; -} - -.is-horizontal { - border-bottom: 1px solid whitesmoke; -} - -.is-horizontal:last-of-type { - border-bottom: 0px; -} - -.buttons .button:not(:last-child) { - margin-right: 0.75rem; -} - -.content-container, .action-container { - border: 1px solid #d3e1e6; - padding: 1em; - margin: 0 !important; -} - -.content-container { - border-bottom: 0px; -} - -.action-container { - box-shadow: 0 0 9px 0 rgba(210, 210, 210, 0.5); -} -.action-container .upload-notes { - border-bottom: 1px solid #d3e1e6; - padding-bottom: 1em; -} - -/* abs preview styles */ -.content-container #abs { - margin: 1em 2em; -} -.content-container #abs .title { - font-weight: 700; - font-size: x-large; -} -.content-container #abs blockquote.abstract { - font-size: 1.15rem; - margin: 1em 2em 2em 4em; -} -.content-container #abs .authors { - margin-top: 20px; - margin-bottom: 0; -} -.content-container #abs .dateline { - text-transform: uppercase; - font-style: normal; - font-size: 0.8rem; - margin-top: 2px; -} -@media screen and (max-width: 768px) { - .content-container #abs blockquote.abstract { - margin: 1em; - } -} - -/* category styles on cross-list page */ -.category-list .action-container { - padding: 0; -} -.category-list form { - margin: 0; -} -.category-list form li { - margin: 0; - padding-top: 0.25em !important; - padding-bottom: 0.25em !important; - display: flex; -} -.category-list form li button { - padding-top: 0 !important; - margin: 0; - height: 1em; -} -.category-list form li span.category-name-label { - flex-grow: 1; -} - -.cross-list-area { - float: right; - width: 40%; - border-left: 1px solid #f2f2f2; -} -@media screen and (max-width: 768px) { - .cross-list-area { - width: 100%; - border-bottom: 1px solid #f2f2f2; - border-left: 0px; - } -} - -.box-new-submission { - text-align: left; -} -.box-new-submission .button { - margin: 0 0 1em 0; -} - -/* tightens up user data display on verify user page */ -.user-info { - margin-bottom: 1rem; -} -.user-info .field { - margin-bottom: 0.5rem; -} -.user-info .field .field-label { - flex-grow: 2; -} -.user-info .field span.field-note { - font-style: italic; -} - -.control .radio { - margin-bottom: 0.25em; -} - -/* Classes for zebra striping and nested directory display */ -ol.file-tree { - margin: 0 0 1rem 0; - list-style-type: none; -} -ol.file-tree li { - margin-top: 0; - padding-top: 0.15em; - padding-bottom: 0.15em; - padding-left: 0.25em; - padding-right: 0.25em; - background-color: white; -} -ol.file-tree li.even { - background-color: #E8E8E8; -} -ol.file-tree li button { - vertical-align: baseline; -} -ol.file-tree form:nth-of-type(odd) li { - background-color: #E8E8E8; -} -ol.file-tree .columns { - margin: 0; -} -ol.file-tree .column { - padding: 0; -} -ol.file-tree .column .columns { - margin: 0; -} -ol.file-tree > li ol { - border-left: 2px solid #CCC; - margin: 0 0 0 1rem; -} -ol.file-tree > li ol li { - padding-left: 1em; -} -ol.file-tree > li ol li:first-child { - margin-top: 0; -} - -/* For highlighting TeX logs */ -.highlight-key { - border-top: 1px solid #d3e1e6; - border-bottom: 1px solid #d3e1e6; - padding: 1em 0 2em 0; -} - -.tex-help { - background-color: rgba(28, 97, 246, 0.4); - /* background-color: rgba(152, 35, 255, 0.40) */ - color: #082031; - padding: 0 0.2em; -} - -.tex-suggestion { - background-color: rgba(255, 245, 35, 0.6); - color: #082031; - padding: 0 0.2em; -} - -.tex-info { - background-color: rgba(0, 104, 173, 0.15); - color: #082031; - padding: 0 0.2em; -} - -.tex-success { - background-color: rgba(60, 181, 33, 0.15); - color: #092c01; - padding: 0 0.2em; -} - -.tex-ignore { - background-color: rgba(183, 183, 183, 0.3); - color: #092c01; - padding: 0 0.2em; -} - -.tex-warning { - background-color: rgba(214, 118, 0, 0.4); - color: #211608; - padding: 0 0.2em; -} - -.tex-danger { - background-color: rgba(204, 3, 0, 0.3); - color: #340f0e; - padding: 0 0.2em; -} - -.tex-fatal { - background-color: #cd0200; - color: white; - padding: 0 0.2em; -} - -/* Classes for condensed, responsive progress bar */ -.progressbar, -.progressbar li a { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - margin: 0; -} - -.progressbar { - /*default styles*/ - /*complete styles*/ - /*active styles*/ - /*controls number of links per row when screen is too narrow for just one row*/ -} -.progressbar li { - background-color: #1c8bd6; - list-style: none; - padding: 0; - margin: 0 0.5px; - transition: 0.3s; - border: 0; - flex-grow: 1; -} -.progressbar li a { - font-weight: 600; - text-decoration: none; - color: #f5fbff; - padding-left: 1em; - padding-right: 1em; - padding-top: 0px; - padding-bottom: 0px; - transition: color 0.3s; - height: 25px; -} -.progressbar li a:hover, -.progressbar li a:focus, -.progressbar li a:active { - text-decoration: none; - border-bottom: 0px; - color: #032121; -} -.progressbar li.is-complete { - background-color: #e9f0f5; -} -.progressbar li.is-complete a { - color: #535E62; - text-decoration: underline; - font-weight: 300; - transition: 0.3s; -} -.progressbar li.is-complete a:hover { - height: 30px; -} -.progressbar li.is-active, -.progressbar li.is-complete.is-active { - background-color: #005e9d; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.25); -} -.progressbar li.is-active a, -.progressbar li.is-complete.is-active a { - color: #f5fbff; - font-weight: 600; - cursor: default; - pointer-events: none; - height: 30px; -} -.progressbar li.is-active a { - text-decoration: none; -} -@media screen and (max-width: 1100px) { - .progressbar li { - width: calc(100% * (1/6) - 10px - 1px); - } -} - -/* info and help bubbles */ -.help-bubble { - position: relative; - display: inline-block; -} -.help-bubble:hover .bubble-text { - visibility: visible; -} -.help-bubble:focus .bubble-text { - visibility: visible; -} -.help-bubble .bubble-text { - position: absolute; - visibility: hidden; - width: 160px; - background-color: #F5FAFE; - border: 1px solid #0068AC; - color: #0068AC; - text-align: center; - padding: 5px; - border-radius: 4px; - bottom: 125%; - left: 50%; - margin-left: -80px; - font-size: 0.8rem; -} -.help-bubble .bubble-text:after { - content: " "; - position: absolute; - top: 100%; - left: 50%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: #0068AC transparent transparent transparent; -} - -/* Formats the autotex log ouput. - * - * Ideally this would also have white-space: nowrap; but it doesnt play nice - * with flex. Unfortunately, the widely accepted solution - * (https://css-tricks.com/flexbox-truncated-text/) is not working here. */ -.log-output { - max-height: 50vh; - height: 50vh; - color: black; - overflow-y: scroll; - overflow-x: scroll; - max-height: 100%; - max-width: 100%; - background-color: whitesmoke; - font-family: "Courier New", Courier, monospace; - font-size: 9pt; - padding: 4px; -} - -/* See https://github.com/jgthms/bulma/issues/1417 */ -.level-is-shrinkable { - flex-shrink: 1; -} - -/* Prevent the links under the mini search bar from wrapping. */ -.mini-search .help { - width: 200%; -} - -/* This forces the content to stay in bounds. This also fixes the overflow - * of selects (without breaking the mini-search bar). */ -.container { - width: 100%; -} - -columns { - max-width: 100%; -} - -column { - max-width: 100%; -} - -.field { - max-width: 100%; -} - -.control-cross-list { - max-width: 80%; -} - -/*# sourceMappingURL=submit.css.map */ diff --git a/submit/static/css/submit.css.map b/submit/static/css/submit.css.map deleted file mode 100644 index 2ebd532..0000000 --- a/submit/static/css/submit.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sourceRoot":"","sources":["../sass/submit.sass"],"names":[],"mappings":";AAAA;AACA;EACE;;;AAEF;AACE;EACA;;;AAEF;EACE;EACA;;AACA;EACE;;AACF;EACE;EACA;;;AACJ;EACE;;;AACF;AACA;EACE;EACA;;;AACF;EACE;;;AAEF;EACE;EACA;EACA;;AAEE;EACE;;;AACN;AAEE;EACE;EACA;EACA;EACA;;;AAEJ;EACE;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;EACA;EACA;;AACA;EANF;IAOI;;;AACF;EARF;IASI;;;;AACJ;AACA;EACE;EACA;;;AACF;EACE;EACA;EACA;;;AAEF;EACE;AACA;EACA;;;AAGA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;EACE;EACA;EACA;EACA;;;AAEJ;EACE;EACA;EACA;EACA;;AACA;EACE;;AACF;EACE;IACE;;;;AAEN;EACE;;AACA;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEJ;AACA;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AACF;EACE;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;;;AACF;EACE;;;AACF;EACE;;AACA;EACE;EACA;;;AAEJ;AACA;EACE;;AACA;EACE;EACA;;AACF;EACE;EACA;;AACF;EACE;EACA;;AACF;EACE;EACA;EACA;EACA;;AACF;EACE;IACE;;;;AAEN;AAEE;EACE;;AACF;EACE;;AACA;EACE;EACA;EACA;EACA;;AACA;EACE;EACA;EACA;;AACF;EACE;;;AAER;EACE;EACA;EACA;;AAEA;EALF;IAMI;IACA;IACA;;;;AAEJ;EACE;;AACA;EACE;;;AACJ;AACA;EACE;;AACA;EACE;;AACA;EACE;;AACF;EACE;;;AAEN;EACE;;;AAEF;AAIA;EACE;EACA;;AACA;EACE;EACA;EACA;EACA;EACA;EACA;;AACA;EACE,kBAdS;;AAeX;EACE;;AAEF;EACE,kBAnBS;;AAoBb;EACE;;AACF;EACE;;AACA;EACE;;AACJ;EACE;EACA;;AACA;EACE;;AACA;EACE;;;AAER;AACA;EACI;EACA;EACA;;;AACJ;EACE;AACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;AACA;AAAA;EAEE;EACA;EACA;EACA;EACA;;;AAEF;AACE;AA0BA;AAWA;AAeA;;AAnDA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACF;AAAA;AAAA;EAGE;EACA;EACA;;AAGF;EACE;;AACF;EACE;EACA;EACA;EACA;;AACA;EACE;;AAGJ;AAAA;EAEE;EACA;;AACF;AAAA;EAEE;EACA;EACA;EACA;EACA;;AACF;EACE;;AAGF;EACE;IACE;;;;AAEN;AACA;EACE;EACA;;AAEE;EACE;;AAEF;EACE;;AACJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGN;AAAA;AAAA;AAAA;AAAA;AAKA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;AACA;EACE;;;AAEF;AACA;EACE;;;AAEF;AAAA;AAEA;EACE;;;AACF;EACE;;;AACF;EACE;;;AACF;EACE;;;AACF;EACE","file":"submit.css"} \ No newline at end of file diff --git a/submit/static/images/github_issues_search_box.png b/submit/static/images/github_issues_search_box.png deleted file mode 100644 index db9a1208ebede67ed0cd6c7fa4472ec0c36106bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17739 zcmZU41y~$SwkQxBg2NDXIhm^9J~OuYrvCdc`HrIe>vdm$DEM zQIHZ5Ay;s;Gqtcbfq{_>OHfDBP#z%2&{mQ%_eYjQX$@1#l|ji8D^}?gmxM#1>I)(_ zl=z`)e@p>)5MCY*T{}SirKLFA^y&IpLDa-Vhha{^BOdOsY1Ql0YxiMiMZj$2PJUp? z%M50$Xj>}JqYwyl7YC;)EQ)O9sKpa8^%hGI4$lwPiBu10(%&!f=2Q1g`(Wzf7~yYx z0M)F(?n}uYN{}Nzj3lTh8ID_kY$Fr4L*`CN6Bb64tgsqR-LM1?~Ng*fyEMlk=}z1kY4Ry!+0_1EZE&;sWN*97Dq92nferjKr;8 zajQScnj7}O9{b>*VjFO_aeZz@3Zy>@*Gm(IWkZSjG+8jTHy3}!7j4iT%hNf=&l5hU zQhabrdU2`xM5~(4D$a;k&?8*>mRUI?^<7?lr**hk+MEGVGKElb``aELVJXqj6tdI| zDm3}AxxtI=?khtXF+lUQ?ERNNHN_DhlK$_+4_{W|R&cHmW$@M5s zA+EFta2Q7r^wHc~Px^9${hCWP36M3VEo4o{Bs+Dx;g4W1RD5@?VIkxl$0HA74dGJh zpNot{{glcgn3u`<7g>>D0DY3$v2b zR^Ri(@S$pQ&f$o`71;`w;po#$xK0NK{rN9C&s;6B?r|KIyDeeTbAgR>Hs*x%@68<0 z>n0zSQGDcx@23(S$+YQ|xMmTk8rHWvfNCht^BgmSn>wc+%=cC7E2{&18jw;jpD-od2`W!(t?xSwz&x1#UgIO)G7 z*5^CvK)Vgp$3mnI;EIKx=y0Ah!3K}Ad?p%t6Cw1z5T>NVQ{ipBpN&4?7W?Q8;~Ewh zh7ZDSCzbu%CI6BFB+#3uwYTp?v4Voeun`R-HOT8jSvR6@$^9kY;Y58Tw~$1Pi;~1b zV8PT3P$9R9wKS$zig^qo7LBGf`}Spv-~wh}OqYU80X;7ZvGBcRNVEt#~_w_LJ{d844 zrPx-1FPHeGXouY^o1$s{HTD2#0Oh%HTFht{U~TZ+(i^Z6nb}jZhI!`m$m;*)GZh!^ zBQm(lSeV0rBP&utYEFntkP9I*pR|jJ;@hu}3%IT8lS$%3A4aHvG`18R*fH3=Vaq+R zIAO-Jj?@UT8hUa{xgM8cPp@rCJU#n0JE`LWZ< zRSVYIeoLo1gax!ZtJ>5$CM$v2ti8E|N|*dYyhD|PquIv7$pV@2y2Gs=@KVqc}H{X6eIoIck+TJadz z@s1Tgt5_oigR>l=5TW+0wsrSzyl#qa!tU;`Il5lF*wm$ z9K{Y(+lqEk;zS5UC{e$oQXe)jV+g zIp;2CP~wybvWUm-!Xo!M-KuH$Pr$xka-(KY@|1PXJW36}Q@%%~NAw-fCFM>l0eXZW zT2(JkL=HiF4Z5ba8fi(mOWd@bh2?vTqRJ)9c>`{96T1X!Rl{jxm(diORv9xxir??eMagvR|%&#}ITMb5cZm!G`w+(V9q zPS68M=RXcZE=ybP11K&R&N$rDTsqF;>8)wKBdKY;{aAgs(cdDAh@85Ece-3@oh|G= zKL6n6@U$M7`%yL8KOZSW*5J9c?~tj(r*jEzXkKxcbW7b++nCw8o>(48Kh@pznGQU` zKB*eZncnPm-#za-Pwij*W9;7N?sGVQayKxzD6=!YH+^WcnZB-b2ABSyeiUm`qc7>`gnc!w12vhdDy=AvJd}&(uJ}}&Y>?9=6HyjyuwnHd88`b!uisAc=sVaF*t*f0w_Yded?;ND=sVA|lO zyR56Go2cu75=6NpP8{)3R9vh)x}}qS^P=%;={al#cUKXr05&#_^_E?+Z&97p;%daBe?}Ey^X%7aA5EvyUfVyyNlW zYFYNbREtzoH1dhjm{>|felJdqO|cj0#P`c(2;6z6NTqOIq4Cs?D}AD@c_cP4#jAI)c6u`Sd<0aw>B9J(-6?3(txmHD?V+P3Gvj-I@R{ zJlYJ3XE1CRdl!u+tfp0UhAEP9#qbF}LKvMG#4sAF^+-X(VkR@PxCC8FZxrz;@{qgR zdW?Jw#DPeaZ*9q5CX2E1=8a6SSj;lCYaW_i4Q3njZiRZMwA6LL*v>J1BF0<&M2d$< zFoV~Yn$F_o`%U*-?p_yh0r%hzh!%tC*#O9&D9`}O*ly{)BposvU2THVjBWf0$-3&A z21J^hS&7*6tfjDJilv-Y;&j{c z$2n&Yr?Z_tMpJFNdhc7^$EV>kZ0*#;qXW7I>;`;WuNFR&hkY_{pTbpx>Kw!Vg7mm_ zon{=@^wOL1idCB?FOxS5Kh5`+SzSi+$V7atZJaHZ7J96U=g_X{uAiTk`{ZYD#Ez|2 zPixxG%@#AQFh65_=cF8VN_o&0Wz;FNC`(78A+@%dq0WSQI!+8kD=QX^JQ6-1hD{z#N{qs%aXZhP9(6kL z?`n;BrQ5Zwd8~KXWwev*ogJ9B?Ln%p;!gAGM^MrwfJi$0K2(2xSEx6tkLpFV&Uoa>a*)eS(reOthakX z>gva&7lJtuHc3ZMgGUyBS%kbLbgMVb$+$kEi5`fVxR!oVaU^^Wj0DG{`%)&-7dSS2bG`;OkdJ z&bMzN3mEaO2pv_L2bXzDp=Z`-t>K$2HRjm2($8=8f_!oCr+dd<=N2gTlA2C1Fa%V8 zPgp6Xk7qD2uxS=58qOMWvOGq1Hb4VoJ3|woyN&%TGz<*Lo#*w|#>Clx+}+07)``cR zkK$htJg@hEtC=Xs{{`Y~#Ydqbr$8=Z=V(IC4rBvRY0b{ElkY7&rJVI`xO=R zx0Xl2!rjDLL)5~?#MbGR2R|1x8|Yu~|6j|0IsOAv^B)mI~|0ycwCyY#3v{AaeHsoJ=orzrYB#nI*qhqCu&HF zVqw9mFMDxl4<>dY{2%0M0{>9t z?8iHbPt-i&;eDctvi`sP=*WAh$df*P(?<4#9tn7^lpRs&{U5@AIkpudhco`jlqMLI zs8Zj)(N_Kd^jZHFVoOQh(`yq%<@-+rMm)YNf9j#KyQ!%C@=x6ik;61F#{x>=#lRz(?SdgO#98q)@`}>Zs6}t}E8n93 zK#a{Ka;pW96S=%=A~xu<@o%ZMG2io^+0ifBLY$6BdT2401h0|STMROVCw-5M%A3gBEWI0OB*(&m z8+cIK^XujZra&1E9Tc$*V+{jaf>hF482&GlDCBn|+91_onu1a6E8XvU-x8b4Tle-j z)Cz>rd+|B1-3DTzM~l9G7^L^<;dnq<)nb*AK2zy&4{TU5aI}Y=AooelyXhrNuRKNL zkCHOE!O%~aGP*$`3m^Z%N03yk!yaL>hl=s-7FE=b7eX;30qWx4=fGnk4{aT~e*B+w z4&t#sUwce0f0TW9JRw#sR|yLzV59&VY~U0ykaso(-AQMDE1BzW4GWVo=JCMpij!y` z$kqIh^Su`K!Q}c>z}I<+^O-22iw}Dos)Szlv+qsD5YSDIZJNO>2GmkC0-x3yP zXOx&3)sgcjh8Tp&DI*4HAQYeIP2#DvoR%KNX; ziF1*Veru(AQMPG1GI9saR#%dCQXv=IgTfr$a<%A;!5dW4K?Vl5=>-NMt=5*TLp z>O1T2khE{|$2vE6Tl?ap9qwxFazlE>N`Z}6^wI~6|4_7_zh63(E#0)IJxCUe9`QQQ zpwg`*fbWVH6fZXv26tgJ8h`o+xL`fSniP1VaKDyKH8VC0jRA-y243^o*}~3MIwSJ{ z-okV9$IWjz3RcjWz84tHub`vu5%Yy^{T~`Df*pJtUCh8S8r-=J!*P0&y9kim4r4%s z9k~92ABdC;ox6*MhHpnPQCkH6Q?ah_OO!w>A2pNr<%U`av%Q0LS#fBjh;W@TA%mTF zgd&@_N4tIeKtqS~9!cqzgbnz;xPq65Bq z)zWjq$1A`;LT<- zIbVCc2U%i{wL{gw?MqX8@m!f5xw%uYOpGV`uRV2>wfv zepY`Yf%y~)iUx*egz0DQQ_o)clKoap{%l8+L4{N`#zNx(jq+T@RWtx_qBjCC?IPMk zMW<1z@A<~j)+-WqXZhYnnHji;`x(H9jd$`65F5L7-0TdiQY5TNhpRr(a_#20HfC^d zGwQ=CHGGf&i6dcEM5ej@{Ni(q+?#+}>hl55q%DQSBqeyaI=*{}lS&$olhL`c^j=-m zMR{^!%8k!(TGSB;Ck%G9U6mY$hi$;N{|0?5o$@)RGfjQRsngZv@?tF=K4I`?IEI3< zP{ALrm{j70OFF%o&%#hAabRXov3t@2Z9G)b$b55$ap&@epnW6_(JD6#_LD+OcRG+B z+>13?ZQxYBPpVd+#*Xi9hY3Vu_1>tO|119M?n~mIxDZj7VdBC7_*37Rf0!znM?p!cs%Ih|w%WI7iFXW)%lGI?sy(I8Er5K5XYV`VBFC?% zrV`2T)#yT~_gKw8cB-Ps;pei1bj5BK*IhO&vw`+6+-}~!JyhNlpQU$`X$bR;enSaR zl?;^u9xxOYlVh2}gDHUmMXJ4_8=IRoJ$VP6Aptc|!A7r#sJr~Zg>O0idxI5i`B12m z^I%jIZCn{}0pB`wd869gKcr;N()KV3Wi(mm(!7~tYQukL=YfZe`|D^OM!6x92N}R- zZKD5NFQ|XRur4l$lB4&4Wk>Dj^SDvJ`6bh0q3$w|O06We6R61}j>M#Cp0wfO3%6VJ zOsS510-f3#SJQ+oKII)^SjS7-U50MjXn8wB!>T8)%kk2OU&J3hT*+S}8?Qnl3xa=| z|7!>DMIVXTUGp|SlD-Xk6S@qoZwl{BVX=fa&4eTZhC374i`6j;`P+1QBOlev3^rI+ z;-R(Pm5r+x%YBp;yLO1Xi+##a2B0=K`O7<`oo<^-#V7bTBUmrx;ATb5tMJQ-Uv*px zdVdqqSLdKQg-FV*zDTMXjw$nTBQk*yN`Uk^17 zuC3GnhgVyTS}_fy6wkR3aVTk7rg+kDToV>6w58p(z3THjBQP|X%k_vWoy#L`#%@mZ zQqQYfcJ(NOcmIUl~It9sjB zp_=7-Ixpj!+Yc7ZYAY}IKh9dkocyh7g@RHH&>gCLChuTTWL}R zKU;uT0Sd0A3J1)p{#KRHD>uaLGIr1$6}t1i$Y5Bv$DQo3bYnhxQfl}j2jR7r7&<7> zmCf_M%bvq8^D}CjGuk1qaCP0gS>@L8A+@S%k-cQ6BlvY5jBeYcX&~#sH`2Oem!#Vd!YQz#}Xk(Yza$*|D zB?W^RR7jxNVOGPY?S}@4K2HD@KA1l|KOsS1e0P1A#J1hBLq3(qFu^|4((dAkcSsKg zqXEnpuhXp)qI2mI=#@Ux+Z(v_@jYB$-pnySM>ERgxnGIKbgYnqs);nHk9M28s*D;A^cd#IxI)i*~IKJwx|Fh71^UYC? za={lxvFG&8Q@P5k8+EnMn=|TUn++BMcJSRoosUgRGV`582#%%vB3GcSo<0ffn06?19i;?`ay!sed=H>>2zt#xNrszKg(( zmZQy4^LdHP>gj&+Jsu(1Ea9o*UdYrRV4(Hk!#%R0_hS!AUecb|{hoMoa?#;AE=`fd zYG<+5GuY?(VVd*Lg_T{Ab700nFYD0?F1^*PO>QGod;YILTbgKlCP?m8AiqO(eqE+~ z8c)2BZoAa7?%4zbQy0wkX#yv~_=ryPafQ;8C(6|Gx|&M9bJ{HHr_)8(qud~#x};E^ zezvR^)1i#rBu?b{_+?VJp@(Hm{;Y;Xpt8wG3QtD#MQ*>$BP2V)Rp)(V!*gr%Lz=x1 z&y)cn6X`jTe47RWn)%Yy7NTdik#=y!)%%gFq5EMuLd9l|mt5M>(Y)jKlRuJQ&lhv7 zXqujxW<8c}+_L)1D%h@>gUEhk-dRz!h2x7wS;mfWyOG zqeG0+usQ*fv@!yd!ca<=1UwMybtL3BSiJ28E}uS7W}G9Ba54ZzH)-T@@ab8pfg*@3 z4pMeEoU0IdWiw-t3?4472y4FVPb=vTEu-2urCDNOk<5A%O8}TEOj<*hr&o$0#g)9BrjshWr8ynOPCsO2iJF{F9CR+H&|2+0F|4JF=lUrpbNf6!M;jH!idb+=g( z?NDjr3|4ng)M_K3sv&W)1i)3fQ!)DR=SVn;NVkINiIf*KK4}Me_8g4*Q_)YR>fuKm z?Q4s8Ja?D63NnBY^8Dn)rv=Z^lk^eyWeCtJ-mh~z0Vyg4`c^D^+`T8&md5GXi7wX-2CR3$id^E{mH2!G@sAP z)9laRjaWfGN9*EnN^q?01RA=Cn0qBm$!G3b;2|XM05aTPj3AMj)G~HG$P?pm61)0x zHyKDm$5N%D&)AeEv2H0%wfizlw%k_#P{H=w(h(iOmm5|q-#ZHILH>J8M({<3G-_~; zM@_RT%!?-V(IKY|v)S}Fed`H@YPv5Xxc#138Hu~HtjnUY{7WXL_2uj2Kvy^iy~*an z^{cBXhWnke0m}+Zo3ev5Nh?d&hMHhh=v*(r!T)e-lkYw`Q!F;*HB9Q4XlmByK1!4M zTr^tH7U`5AMiB>~;ESwyKIoVrjLniKD4f-GF#Z{M?*2n1RMoaS(yqu&UH>p|^QVUC zc{eL^Gzl~l$fc2>l`bWmZnbaMuq-=U=|#Z6B$Eg+*2-VwXGsC`O+;f zi{ngxqvu9sA^u$HZsD&A2ag6cZFW~>jTLG2MMqwW954!STt+up;<;k((@VFqs+KYO z5bPwUOGua<7GaC@>p;itF=6==Jsn{9cA*Z>)HagWD?KhrRH$bo+e;)mpd%shH5W5M z=m&QM)3jk!(Dl(I-jdZ@i~4^Rs9>(Cs1SkJQUMhvBrr%iTHPB#Ue9y-=md!0V}|J= zn&30HcI-HMj+1@=Hish?qw* za)j(`=c|<#xK#WQkN0EEnpyiRR}Pnr^(}J`X6qYywdMm#={5@eA=(YOAzF3FgYJ^1 z5rabY&^@2&tG9{0CWo$+Tx6+Y9OzE=ZQ5Rqw-nfF7tBt-xhkO4x3}%jGdRQu)ld1t|l1 zY25Qh^}fF^oy?~RySK4fcPoW8jcCv2zbZvX0Y11!r05hOe6w22&(kFRf)yCYWtvSP z47DBJEC@Dv6f~IafkE{>BpCiSgeN=oha}B>Bf3?&G9;XU-l0>e7vkkQ9W#Z!mE(0P z6f{8d>()&C^WCR{kvDg5)VZKCLgF#RveU(CYPWs#NU+xu_q0?D+6Q2?4izVYKPU>ur}>QR9!QOw`b9?hSkBsYq-5DdMpU`%LOjwG zz8!vNUAdZ-Yt<}|0n_wLYR_QY4@EZ?D~Iv5!SN^n-l*^G9o~)=nID=U^J$ZW|4v83yPlhNH;5ZP zi%G9W+xy<<WpL`t#?Rgf|vhAlG}z&*f6DCO1UH;)N;F%ps(D z+Z`mcR&6n;wgBA+;A{J7;4&h^oGuMl&IqqR-wf>%Z_>n;%Z&Qu_v){d(&oN7FOz1( zo93RR@r3AQwHQZ_jg55&>4cv-uUlEpfwk#Mqm!L1?LQ-_r+Ig^9 zxgxE4AG@-gsX##Qg58lB89MlgfVO|h6g5C?O&C5blHSwAGf&dsMC8tbQtf%n<7xYK zoI@j`FPiY+4-b{yqL9=kb%|N!pN@O{&>$Y$G6=dAv1B|`6%L@;MZVqW_|iU-SI=c) zQ#dq2T=#$C!|P&OtqZ-;i-UtOE!{&#VC=2rG+~LLYQv|j^q`Q;2*B~xOipc zX|`@f%9^Nw;jsiSawnLLVN(_JIO49QOp_JRIUU0gPExGOIW4DiLNA&(95uMHs1nu#vFC84LkY zf{aWyTK=`Q$a!29Zuk!CYu?SWkJ+J3R~D<1^@U<|VrVr(Gs&h8ykBP5SNxPJl)m2n zxSyS-)^O}%Y?JIs^Qf5MTzx4F59F1<^g7MwJ%tV{qM+A*SqHM#xV%k(lAjUIP=!|f zgn+a!FFjL0%b|*aIA|suSm$Ek(g;*?vYt`4_8ZdeV%(#ox|!~#s_giOIQFt>{NK{n zWzsgOYD;SMSPyY4Z|0QJPO7hXnE3s|u%K~oWe5v&{k2J!4%2#yj|}U{SdX-715g3| z0C#^Fn!D%z1U9Ra!GrM}8*QXIcVY1!F{E5dCoC3ThuJItX8T!P#QjXvSHef~kcWi>y00gJ0ekYM!%4CZR5Hc)T#eyxiIy@+ zDkATB8p9nZhjzb|8}1-}-#=c4CNt7X`44An9!9-ON|vqmn`P6E9861A9^Mu`b~jAk z0WLozouJKt955Pi848w;PI!wtuY3nod>Jr0F5o6E&O{hl6WaS$En2`Uek{uf-xh=Z ztUKbr5$AP1$?D$!t?@|>r{pT%d!CC62?sXwvMo?NspjBbXXuE!XVw(Z zg{Z>$yzp~?ftUWFO>R~WJi^Y=l=DAF*U>;;~W^uT)bQE{I-kGoRILKHtg5;ra*jPd2 zy5)WwK4MP}jdZC{EPOFhQNeG56b9Hr$jAn(2))5_V1;u}UjW_Z0 zK~zgT?8@mW$tOb}lMTAIN_GdF-BevvuNK+U@9)!!QJOx&ZR7f^KsEC@qM}D=5^v%b z+@hILOEjwBS_QmP9FA;vtV;&YiiQLS<4&9UwVh*%%DS6M#BeG1un4>l>ry>v4Jc9=vYW^69*Fta9|0 z#?o3GBqLm>O0pZ_s4ORoZJl^HII^9cofo?MH-pCoNAwDjho!hvxmkjh456% zl;$ZKJpP0Y#eYw`50OPjLCM+~CRC=ax)O7*_p;;heFjZ86;nr%aQ9?|=k!NUmNt@2 zjOg8P^^^pn;{R421vF0xq~ISt97OP-P0pZq-X+W;{vK?+tk*7-XfT`3To1yaQU#{vQWOlwGX=vI~#1<&m zl%F(fXTs!lbN@~XftD@l-S^JcZe3PvJ)Z@^s^8T%?fnP=~F_7d}caSCfEW2K22ZYU*%817FMO(fa{DOz)F#S zF@Uja#4IgqGXmgrlJ1U@E_)u#fhQ$;? z(i?%t7}LO{;=#jNbGZKJyNISR6l}kq2+eM30oox0fRX@GZ(`L4%i{MIj0D^!5Mp>G*!!f6}R zA%mngA3l%)YvslKTMM-gbSUW(w(Q;_J#x3xYxo!8oh~PhT7~X#PAAo_3k{eHQy?^6 z`DXl@qk8#fu2q z4{zY4m+Pr?LxU~V&6UuxRcn*7%sIFgHD@JVa?$PN#{h1B^v_UBRVh1>5`y6(ml6WC z0-|%1-$AJ7OH*8GZ&Z9y5_K#3C&MDq=SkfHjj0@OlldUR*&vBibUh(*z4HU_!SfW(L5igcDvBHtp&P?HvdaYiX|w1yM!~EgwQT0) z)puOP@GLcw&YdTCJbtJ{zH$Pr*4EXi1pyftJ)4?c@!9w^HNX=PYX<`$aWpBf(8KdD zT77II=0-D0B-VMU}`(0wb%8!;YfKW!_74!^b z!0!Dszth(rz728w4;Ms~7DJ&QoL$ON`l_Z!o&_+KmSVA4k}HBM2Iyo{*<;iyo;0Zs z?e4FHZ2J32^RHw0jaA|I-#o^wq|%Yb2>t1QeuF3AoD145U(}wIVFDR>w5bLV{=Aog z?h(WfY_4P#cfr{be97PuW4d`y9$#)Z=ZDyT760JbZb(nA{iY1=M(?EGnpTZ~sHBBX zm#O{plUjSf8immGc9{LSYkQ4l`~d(9cyOYR21ga*HyR`(<%-{2b~QXzQE|9d;gqiZK-Sl_--lQnM6B`PqYx3^0*((YivML-#--6*=o zlfq%aa5IgR__D>ijL!MVc61|Vw`fb)X5J<;uwtqEkGKwoI&&*O0p){_fd1JAo9i7a z8oe3RM+XaNVm4}uZKtYV{(D-s_k0Gh(JL!zugAqG${M0@&e3e0%;Ko`qOacN;~-*! z*G{|3xs!otH>3HzUKfvgGYO#kZ8*AAF}*Klq{jAO7KhV4s4{@$yJD05e69zCW9If#)~v5B|HxsK0ihv8~(QZHqZ`=r%k^Q{{jM1e@HoLDY1 zNit75L8*XnQK$K4BDCkD2Uslhu4q$Y0Mb^!du*fsMVThDe#_<09`jub9SwMW&M@oW zcCB=YE^{U(g}#u6dsN3;rgUF#2FvW1*uBtio;2?Q4+t-VmUbWdZejwH(F$a-$d0-O zD8fX1p43gp_`?_MuDqcO4l8Wc?`u%2e@3DybqYjUwv z6#t6Fm(nY4a$WDvjEmhw74SfXxtG35SVM2nbv9nDIV(P$g)Oq7f*ZE_k?CoJh=;no z@re?%X~^l!G+oTb>PCI_XZs63WBv}jdhO} zdWFCaFOu$7wMS%W1%tRGdz~-MiEbwPZtSjP8}>+}d^lbC9nDX3>LBfx$-Ou#*ATnOkHp64G_&N#0cnP~J5c>C}#L za!O)nK)G7)+$}P&x$(mRXoa9|_26bs`eWfe2pgC-oBHoCF9{HsH%vQL-We`+?$OolF*il}+5?V7I$4rX`p zJs^M}_Pke3_h{eMV0heS61V3GoUoDa`0p%fD$|yNH9dTUz`fM6$zMu*z?%y4K}ndn zss+)N#E(G4$z%)*d4W4F7x$ypK8QkC#u@8m$jOKsa8}1>_s`%yUH*ZZHo<3We{Pq1 z^zAfWnlgqoHEK2$Eqyvw1}2*Gn)Wu-krPw8mDGPl6uyeL?_?#OdFVx(ACJVyj%_dByMoHKXLipL;uNowcZU`YV5K>YFAQFy9{#or@D?AI#(<^=x_g5LD66=a(%K z6Nn|naxEEjVzUcgCEBkaPLu(?y?s-gdp^3Rx=Ueai)k8j$u-oUF(WM>boC!a&r1&h z==Aty!}VQnl+f>dJE4l*pc8eluHt=}PfVF8+-zs3^7_WGkJ&W!G!r@4`UWwRTpSG}2*pZrd>9-C1)I1;fGUZ=CqcW>?M;v)NLveL6#;xPHu? zfjf_qaK^hJb^OgE50PuBNz*aR>xQr5LWUtUGnjj`>PPcm4+dyy*;pLX*4uJ+r*AZC zBj5ed_wBYRp^-u_R;$poGOp1C2i1vNg%pSMYFIz&8|fRS8COp}_L*LIiU`lW?a@@i z8{Pa~%fQ^`7sWY`Z^-EX`m<=+_UtAsxKSRK!*x)s$)OyLm=xRwWm28sAx{xw{PleZv z4;4Uwk6SRpq82>4Sa5nt=LGR;H9o*-(5Rf;rQ6>vg7_3+t+qEf(<(b;*rF}|XAmL! zW9HA>*%9!+sh(AcC2u`B44l76hg+AJ2AJzzxnX!RA(Gdfh8zASH%ZO03^LK?nfHMa zpsb)ywbz!QWVPq|(1=&0*6r7sTMwOVdofO^IzDT_ljqdZcN0Bdnf--}?gpMwH|)E{ zwq&QBFpp0}U_?9+%|;u?OZK&)pQ;>`aQ`@y1;SDG=#!v$Rce77yeJQqYbex+VnVAn z(PJ!NYPn`I`GPH);B{?1(DMxOTQ z?6zZ5T}5MxI40TF7^oh4Hc@ZcsLJ?JAn8#%UY`r}?hRJo5j<4vLFbYV%ym^^D8t>Ol@S7cLZ7y4=E2+-5N6ts2@^n$ zP`CJi(;UPiGzsg(`{mZV-cvU7lK&ixrvo~>Vk3~ry3BPjyAk3a^-vhJl_&FVk<^mr zN`I5+p!2Fx8QEt}`tBctu6p_5+dlJ-c@6%PsjG9}dFs4^<_I^5r=)f6gWPsO_m8o= zP0j=a-}cL%K{PdRG}5>FVW$Y`1dXJp3*o3dQEx3Ts8Nqr zT_hoqQMDl~VQDU^w03})B4Kp7{xzS>TspO#X07|fvg#yHfceR~{W4b$3wYxBWw-*UD5BMLKAfSNk#(vOyk}sIN0(;^&-t21bVk9 z5elpw%oH7y<8dv=cU+NyzKcJ?`7M{YlO`p9KFej0zdSSccqU0@IM4P^W0;+4`fc-&R-$iEywEXQFxu=e zqD>rg*AO>>LH13q#6{KE-}fc_;UiOM=TmrQ_Yup^@LG(|VfhonzT3&~4QFJ4{HG6? z$ney#nP`B!H{U^y>Xfq9!=UDl0>7s&viQr^JLJ!}Ody*4ySL>8Fm^K$)q)+$aB5w> zlb7Ex;l0IRx_Z@(6@kcjzHs~iptXY#46Gfl!t2|fj{T*zjCoqbyHKu%Y0Z{Ou^bOp zWPsThu4_zKWWbBPMA-?7AQWmAL`;rV2oH>~A7F)UHS*8vR&HuEiu)rGWrI#JTZ-qf znYmY!R}zR{j=V5avHei(H3Umq}|iRHHGxh<<>mv*cT;O@uR%zu@+!_v@G8tH5%@>&tbz zd_hCBfAM`~{Tdm?{$eeEl?zb85<`oK$f0l`t=f3!ElXVmol_!y2umoL)6f9J9ijx6 zz`|m|{O90>B|`;l(^32QDG?=xudZ}4yT6NI!>bqg6cKTGR|#F4NBjp5sg97YU`C*! zD)VEsjQ%yTfS#(f7Dk>~nwtdz0}v>Q1^ZuMSfN*7F@_KZ;f#``jXbcAy)`8aDTjJv zz!efSoL2Ym%-!$bhu{GD8tZ!X^{(6mRcTmK8m2n)c=FMG&wL$9f3OpY?7!0R%2zA( z$L^EzM?Nj}!>60X-?Lf`NAK z-tZh&1I*CwDfvIL|6OAG_{zA80DzISi4*(OcM3@O?F80$IDdc$zWHB+2(CF|pB32~ z0GPMw^1<1ab+0ZdGS1bZdgRDQp@8{6QotbNVZ!m_0f~C?FHWIsw500cYVdfkJmC&p zHw^zvt%LFJa>eV5W`hH)QH1QdN_jHx<0q#-x>)zNj{A%vfKdA=@#8O-eHy>P{#RJI zu)i+#rIRGA1qy$aTAKr>7u-D<+;R~G6jK;Nedib*`-lphg@^XSZ5U2_A& z?Vkr({ny<7Ah;%b?zCi?(V>C7i>$;#y;LWUR!;|x#u?_dkR`S2^9(% zuPK@fEjl*20v?<_K0PYmtHb~G@_!xbq_8^B$D}lw?!;}S)===l#KaX1OE$&eSlu_1 zZLuaD4DQ_}?uA;H5NAL{A%W00cB>?9a47?GC=wGZT6(J1G_wmUUh*5Gg zhw`@m$go{`rJ7qP@Fiwgu3OsB;^T;NvKI;(m;`K_xPJt572q-X9wq(&u=k7kcU0Nf zV!F>ELV zGee_x{{d6M)EB#=wt4Zexen(xn-i(pj@_FOuSV zxX2^nwK$W2UfsbyFGQ>?h!9G+7_{6?a+QqOqf2%i;^HbDJLUk(Mm_c1E3AmLadbMv z?)IyF9IENw-C2e$-AavX { - var author = document.getElementById("authorship-0"); - var not_author = document.getElementById("authorship-1"); - not_author.onchange = handle_not_author; - author.onchange = handle_not_author; - handle_not_author(0); -}); diff --git a/submit/static/js/filewidget.js b/submit/static/js/filewidget.js deleted file mode 100644 index b897267..0000000 --- a/submit/static/js/filewidget.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Populate the upload selection widget with a filename. - **/ -var update_filename = function() { - if(file.files.length > 0) { - document.getElementById('filename').innerHTML = file.files[0].name; - document.getElementById('file-submit').disabled = false; - } - else { - document.getElementById('file-submit').disabled = true; - } -}; - -/** - * Bind the filename updater to the upload widget. - **/ -window.addEventListener('DOMContentLoaded', function() { - var file = document.getElementById('file'); - file.onchange = update_filename; -}); diff --git a/submit/static/sass/manage_submissions.sass b/submit/static/sass/manage_submissions.sass deleted file mode 100644 index 787fbfb..0000000 --- a/submit/static/sass/manage_submissions.sass +++ /dev/null @@ -1,112 +0,0 @@ -//colors -$color_celeste_approx: #ccc - -@media only screen and (max-width: 320px) - .box - padding: unset - padding-bottom: 0.75em - -@media only screen and(max-width: 760px),(min-device-width: 768px) and(max-device-width: 1024px) - table - display: block - - thead - display: block - tr - position: absolute - top: -9999px - left: -9999px - - - tbody - display: block - - th - display: block - - td - display: block - border: none - position: relative - padding-left: 30% - &:before - position: absolute - top: 6px - left: 6px - width: 45% - padding-right: 10px - white-space: nowrap - - &.user-submission - &:nth-of-type(1):before - content: "Status" - font-weight: bold - - &:nth-of-type(2):before - content: "Identifier" - font-weight: bold - - &:nth-of-type(3):before - content: "Title" - font-weight: bold - - &:nth-of-type(4):before - content: "Created" - font-weight: bold - - &:nth-of-type(5):before - content: "Actions" - font-weight: bold - - - &.user-announced - &:nth-of-type(1):before - content: "Identifier" - font-weight: bold - - &:nth-of-type(2):before - content: "Primary Classification" - font-weight: bold - - &:nth-of-type(3):before - content: "Title" - font-weight: bold - - &:nth-of-type(4):before - content: "Actions" - font-weight: bold - - - - tr - display: block - border: 1px solid $color_celeste_approx - - .table - td - padding-left: 30% - - th - padding-left: 30% - - -.box-new-submission - padding: 2rem important - -.button-create-submission - margin-bottom: 1.5rem - -.button-delete-submission - border: 0px - text-decoration: none - - -/* Welcome message -- "alpha" box */ - -/* This is the subtitle text in the "alpha" box. */ -.subtitle-intro - margin-left: 1.25em - -.alpha-before - position: relative - margin-left: 2em diff --git a/submit/static/sass/submit.sass b/submit/static/sass/submit.sass deleted file mode 100644 index f3f2774..0000000 --- a/submit/static/sass/submit.sass +++ /dev/null @@ -1,440 +0,0 @@ -/* Cuts down on unnecessary whitespace at top. Consider move to base. */ -main - padding: 1rem 1.5rem - -.section - /* move this to base as $section-padding */ - padding: 1em 0 - -.button, p .button - font-family: "Open Sans", "Lucida Grande", "Helvetica Neue", Helvetica, Arial, sans-serif - font-size: 1rem - &.is-short - height: 1.75em - svg.icon - top: 0 - font-size: .9em -.button.reprocess - margin-top: .5em -/* Allows text in a button to wrap responsively. */ -.button-is-wrappable - height: 100% - white-space: normal -.button-feedback - vertical-align: baseline - -.submit-nav - margin-top: 1.75rem - margin-bottom: 2em !important - justify-content: flex-end - .button - .icon - margin-right: 0 !important -/* controls display of close button for messages */ -.message - button.notification-dismiss - position: relative - z-index: 1 - top: 30px - margin-left: calc(100% - 30px) - -.form-margin - margin: .4em 0 - -.notifications - margin-bottom: 1em - -.policy-scroll - margin: 0 - margin-bottom: -1em - padding: 1em 3em - max-height: 400px - overflow-y: scroll - @media screen and (max-width: 768px) - padding: 1em - @media screen and (max-width: 400px) - max-height: 250px -/* forces display of scrollbar in webkit browsers */ -.policy-scroll::-webkit-scrollbar - -webkit-appearance: none - width: 7px -.policy-scroll::-webkit-scrollbar-thumb - border-radius: 4px - background-color: rgba(0,0,0,.5) - -webkit-box-shadow: 0 0 1px rgba(255,255,255,.5) - -nav.submit-pagination - display: block - /*float: right*/ - width: 100% - -h2 - .title-submit - margin-bottom: 0em - margin-top: 0.25em !important - display: block - float: left - width: 10% - display: none - color: #005e9d - .replacement - margin-top: -2rem - margin-bottom: 2rem - font-style: italic - font-weight: 400 - -h1.title.title-submit - margin-top: .75em - margin-bottom: 0.5em - clear: both - color: #005e9d !important - .preamble - color: #7f8d93 - @media screen and (max-width: 768px) - .title.title-submit - margin-top: 0 - -.texlive-summary article - padding: .8rem - &:not(:last-child) - margin-bottom: .5em - border-bottom-width: 1px - border-bottom-style: dotted - -.alpha-before - &::before - content: '\03b1' - color: hsla(0%, 0%, 80%, .5) - font-size: 10em - position: absolute - top: -0.4em - left: -0.25em - line-height: 1 - font-family: serif - -.beta-before - &::before - content: '\03b2' - color: hsla(0%, 0%, 80%, .5) - font-size: 10em - position: absolute - top: -0.4em - left: -0.25em - line-height: 1 - font-family: serif - -/* overrides for this form only? may move to base */ -.field:not(:last-child) - margin-bottom: 1.25em - -.label:not(:last-child) - margin-bottom: .25em - -.is-horizontal - border-bottom: 1px solid whitesmoke -.is-horizontal:last-of-type - border-bottom: 0px - -.buttons .button:not(:last-child) - margin-right: .75rem - -.content-container, .action-container - border: 1px solid #d3e1e6 - padding: 1em - margin: 0 !important -.content-container - border-bottom: 0px -.action-container - box-shadow: 0 0 9px 0 rgba(210, 210, 210, .5) - .upload-notes - border-bottom: 1px solid #d3e1e6 - padding-bottom: 1em - -/* abs preview styles */ -.content-container #abs - margin: 1em 2em - .title - font-weight: 700 - font-size: x-large - blockquote.abstract - font-size: 1.15rem - margin: 1em 2em 2em 4em - .authors - margin-top: 20px - margin-bottom: 0 - .dateline - text-transform: uppercase - font-style: normal - font-size: .8rem - margin-top: 2px - @media screen and (max-width: 768px) - blockquote.abstract - margin: 1em - -/* category styles on cross-list page */ -.category-list - .action-container - padding: 0 - form - margin: 0 - li - margin: 0 - padding-top: .25em !important - padding-bottom: .25em !important - display: flex - button - padding-top: 0 !important - margin: 0 - height: 1em - span.category-name-label - flex-grow: 1 - -.cross-list-area - float: right - width: 40% - border-left: 1px solid #f2f2f2 - - @media screen and (max-width: 768px) - width: 100% - border-bottom: 1px solid #f2f2f2 - border-left: 0px - -.box-new-submission - text-align: left - .button - margin: 0 0 1em 0 -/* tightens up user data display on verify user page */ -.user-info - margin-bottom: 1rem - .field - margin-bottom: .5rem - .field-label - flex-grow: 2 - span.field-note - font-style: italic - -.control .radio - margin-bottom: .25em - -/* Classes for zebra striping and nested directory display */ -$stripe-color: #E8E8E8 -$directory-color: #CCC - -ol.file-tree - margin: 0 0 1rem 0 - list-style-type: none - li - margin-top: 0 - padding-top: .15em - padding-bottom: .15em - padding-left: .25em - padding-right: .25em - background-color: white - &.even - background-color: $stripe-color - button - vertical-align: baseline - form:nth-of-type(odd) - li - background-color: $stripe-color - .columns - margin: 0 - .column - padding: 0 - .columns - margin: 0 - > li ol - border-left: 2px solid $directory-color - margin: 0 0 0 1rem - li - padding-left: 1em - &:first-child - margin-top: 0 - -/* For highlighting TeX logs */ -.highlight-key - border-top: 1px solid #d3e1e6 - border-bottom: 1px solid #d3e1e6 - padding: 1em 0 2em 0 -.tex-help - background-color: rgba(28, 97, 246, 0.40) - /* background-color: rgba(152, 35, 255, 0.40) */ - color: rgba(8, 32, 49, 1) - padding: 0 0.2em - -.tex-suggestion - background-color: rgba(255, 245, 35, 0.60) - color: rgba(8, 32, 49, 1) - padding: 0 0.2em - -.tex-info - background-color: rgba(0, 104, 173, 0.15) - color: rgba(8, 32, 49, 1) - padding: 0 0.2em - -.tex-success - background-color: rgba(60, 181, 33, 0.15) - color: rgba(9, 44, 1, 1) - padding: 0 0.2em - -.tex-ignore - background-color: rgba(183, 183, 183, 0.30) - color: rgba(9, 44, 1, 1) - padding: 0 0.2em - -.tex-warning - background-color: rgba(214, 118, 0, 0.4) - color: rgba(33, 22, 8, 1) - padding: 0 0.2em - -.tex-danger - background-color: rgba(204, 3, 0, 0.3) - color: rgba(52, 15, 14, 1) - padding: 0 0.2em - -.tex-fatal - background-color: rgba(205, 2, 0, 1) - color: rgba(255,255,255,1) - padding: 0 0.2em - - -/* Classes for condensed, responsive progress bar */ -.progressbar, -.progressbar li a - display: flex - flex-wrap: wrap - justify-content: center - align-items: center - margin: 0 - -.progressbar - /*default styles*/ - li - background-color: #1c8bd6 - list-style: none - padding: 0 - margin: 0 .5px - transition: 0.3s - border: 0 - flex-grow: 1 - li a - font-weight: 600 - text-decoration: none - color: #f5fbff - padding-left: 1em - padding-right: 1em - padding-top: 0px - padding-bottom: 0px - transition: color 0.3s - height: 25px - li a:hover, - li a:focus, - li a:active - text-decoration: none - border-bottom: 0px - color: #032121 - - /*complete styles*/ - li.is-complete - background-color: #e9f0f5 - li.is-complete a - color: #535E62 - text-decoration: underline - font-weight: 300 - transition: 0.3s - &:hover - height: 30px - - /*active styles*/ - li.is-active, - li.is-complete.is-active - background-color: #005e9d - box-shadow: 0 0 10px 0 rgba(0,0,0,.25) - li.is-active a, - li.is-complete.is-active a - color: #f5fbff - font-weight: 600 - cursor: default - pointer-events: none - height: 30px - li.is-active a - text-decoration: none - - /*controls number of links per row when screen is too narrow for just one row*/ - @media screen and (max-width: 1100px) - li - width: calc(100% * (1/6) - 10px - 1px) - -/* info and help bubbles */ -.help-bubble - position: relative - display: inline-block - &:hover - .bubble-text - visibility: visible - &:focus - .bubble-text - visibility: visible - .bubble-text - position: absolute - visibility: hidden - width: 160px - background-color: #F5FAFE - border: 1px solid #0068AC - color: #0068AC - text-align: center - padding: 5px - border-radius: 4px - bottom: 125% - left: 50% - margin-left: -80px - font-size: .8rem - &:after - content: " " - position: absolute - top: 100% - left: 50% - margin-left: -5px - border-width: 5px - border-style: solid - border-color: #0068AC transparent transparent transparent - - -/* Formats the autotex log ouput. - - Ideally this would also have white-space: nowrap; but it doesnt play nice - with flex. Unfortunately, the widely accepted solution - (https://css-tricks.com/flexbox-truncated-text/) is not working here. */ -.log-output - max-height: 50vh - height: 50vh - color: black - overflow-y: scroll - overflow-x: scroll - max-height: 100% - max-width: 100% - background-color: whitesmoke - font-family: 'Courier New', Courier, monospace - font-size: 9pt - padding: 4px - -/* See https://github.com/jgthms/bulma/issues/1417 */ -.level-is-shrinkable - flex-shrink: 1 - -/* Prevent the links under the mini search bar from wrapping. */ -.mini-search .help - width: 200% - -/* This forces the content to stay in bounds. This also fixes the overflow - of selects (without breaking the mini-search bar). */ -.container - width: 100% -columns - max-width: 100% -column - max-width: 100% -.field - max-width: 100% -.control-cross-list - max-width: 80% diff --git a/submit/templates/submit/add_metadata.html b/submit/templates/submit/add_metadata.html deleted file mode 100644 index da97048..0000000 --- a/submit/templates/submit/add_metadata.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Add or Edit Metadata{%- endblock title %} - -{% block within_content %} -
    -
    - {{ form.csrf_token }} - {% with field = form.title %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.authors_display %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="textarea is-danger")|safe }} - {% else %} - {{ field(class="textarea")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.abstract %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="textarea is-danger")|safe }} - {% else %} - {{ field(class="textarea")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.comments %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/add_optional_metadata.html b/submit/templates/submit/add_optional_metadata.html deleted file mode 100644 index e12b7eb..0000000 --- a/submit/templates/submit/add_optional_metadata.html +++ /dev/null @@ -1,165 +0,0 @@ -{% extends "submit/base.html" %} - - -{% block title -%}Add or Edit Optional Metadata{%- endblock title %} - -{% block within_content %} -
    -
    - {{ form.csrf_token }} -
    -
    - {% with field = form.doi %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.journal_ref %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.report_num %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.acm_class %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.msc_class %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} -
    -
    -
    -
    -

    - If this article has not yet been published elsewhere, this information can be added at any later time without submitting a new version. -

    -
    -
    -
    -
    -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/admin_macros.html b/submit/templates/submit/admin_macros.html deleted file mode 100644 index 10e53bc..0000000 --- a/submit/templates/submit/admin_macros.html +++ /dev/null @@ -1,76 +0,0 @@ -{% macro admin_upload(submission_id) %} -
    -
    -
    -

    Admin

    - - -
    -
    - - - - {% user.fullname %} 2019-04-20 -
    -
    -
    -
    -

    Checkpoints

    -
    -
    - - 2019-04-11T09:57:00Z - - - Title might be longer than this - -
    - -
    -
    -
    - - 2019-04-11T09:57:00Z - - - Title might be longer than this - -
    - -
    -
    -
    -
    -{% endmacro %} diff --git a/submit/templates/submit/authorship.html b/submit/templates/submit/authorship.html deleted file mode 100644 index 9c043e6..0000000 --- a/submit/templates/submit/authorship.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "submit/base.html" %} - -{% block addl_head %} -{{super()}} - -{% endblock addl_head %} - -{% block title -%}Confirm Authorship{%- endblock title %} - -{% block within_content %} -
    -{{ form.csrf_token }} -
    -
    -
    - Confirm the authorship of this submission - {% if form.authorship.errors %}
    {% endif %} - {% for field in form.authorship %} -
    -
    -
    - {{ field|safe }} - {{ field.label }} -
    -
    -
    - {% endfor %} - -
    -
    - - {% if form.proxy.errors %} - {% for error in form.proxy.errors %} -

    {{ error }}

    - {% endfor %} - {% endif %} -
    -
    - {% if form.authorship.errors %} - {% for error in form.authorship.errors %} -

    {{ error }}

    - {% endfor %} - {% endif %} - {% if form.authorship.errors %}
    {% endif %} -
    - -
    -
    -
    -

    Authorship guidelines

    -

    Complete and accurate authorship is required and will be displayed in the public metadata.

    -

    Third party submissions may have additional requirements, learn more on the detailed help page.

    -
    -
    -
    -
    -
    -{{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/base.html b/submit/templates/submit/base.html deleted file mode 100644 index 17593e2..0000000 --- a/submit/templates/submit/base.html +++ /dev/null @@ -1,61 +0,0 @@ -{%- extends "base/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} -{% import "base/macros.html" as macros %} - -{% block addl_head %} - -{% endblock addl_head %} - -{% block alerts %} -{# don't show alerts in the place the base templates show them #} -{% endblock alerts %} - -{% block content %} - {% if submission_id and workflow %} - {{ submit_macros.progress_bar(submission_id, workflow, this_stage) }} - {% endif %} - -

    - {% block title_preamble %} - {% if submission and submission.version > 1 %}Replace:{% else %}Submit:{% endif %} - {% endblock %} - {% block title %}{% endblock title %} -

    - - {# alerts from base macro #} - {{ macros.alerts(get_alerts()) }} - {# Sometimes we need to show an alert immediately, without a redirect. #} - {% block immediate_alerts %} - {% if immediate_alerts -%} - - {%- endif %} - {% block more_notifications %}{% endblock more_notifications %} - {% endblock %} - - - {% if submission and submission.version > 1 %} - {# TODO: change this when we have better semantics on Submission domain class (e.g. last announced version) #} -

    Replacing arXiv:{{ submission.arxiv_id }}v{{ submission.version - 1 }} {{ submission.metadata.title }}

    - {% endif %} - {% if form and form.errors %} - {% if form.errors.events %} -
    - {% for error in form.errors.events -%} -
  • {{ error }}
  • - {%- endfor %} -
    - {% endif %} - {% endif %} - {% block within_content %} - Specific content here - {% endblock within_content %} -{% endblock content %} diff --git a/submit/templates/submit/classification.html b/submit/templates/submit/classification.html deleted file mode 100644 index bc669a6..0000000 --- a/submit/templates/submit/classification.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Choose a Primary Category{%- endblock title %} - -{% block more_notifications -%} - {% if submitter.endorsements|endorsetype == 'None' %} -
    -
    -

    Endorsements

    -
    -
    -
    - -
    -

    Your account does not currently have any endorsed categories. You will need to seek endorsement before submitting.

    -
    -
    - {% endif %} - {% if submitter.endorsements|endorsetype == 'Some' %} - - {% endif %} -{%- endblock more_notifications %} - -{% block within_content %} - -

    Select the primary category that is most specific for your article. If there is interest in more than one category for your article, you may choose cross-lists as secondary categories on the next step.

    -

    Category selection is subject to moderation which may cause a delay in announcement in some cases. Click here to view a complete list of categories and descriptions.

    -
    -
    - {{ form.csrf_token }} -
    -
    - {% with field = form.category %} -
    -
    - -
    - {% if field.errors %} - {{ field(class="is-danger")|safe }} - {% else %} - {{ field()|safe }} - {% endif %} -
    - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} -
    -
    - {% endwith %} -
    - -
    -
    -
    -

    - - Select a category from the dropdown to view its description -

    -
    -
    -
    - -
    -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_cancel_request.html b/submit/templates/submit/confirm_cancel_request.html deleted file mode 100644 index 2838ab1..0000000 --- a/submit/templates/submit/confirm_cancel_request.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "submit/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block title_preamble %}{% endblock %} -{% block title -%}Cancel {{ user_request.NAME }} Request for E-print {{ submission.arxiv_id }}{%- endblock title %} - - -{% block within_content %} -

    {{ submission.metadata.title }}

    - - -
    - {{ form.csrf_token }} -
    -
    -
    -
    -

    Delete {{ user_request.NAME }} Request

    -
    -
    -

    - Are you sure that you want to cancel this {{ user_request.NAME }} request? -

    - {{ form.csrf_token }} - -
    - - No, keep working -
    -
    -
    -
    -
    -
    -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete.html b/submit/templates/submit/confirm_delete.html deleted file mode 100644 index 5f1fac5..0000000 --- a/submit/templates/submit/confirm_delete.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "submit/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block title -%}Delete Files{%- endblock title %} - -{% block within_content %} -
    - {{ form.csrf_token }} -
    -
    -
    -

    - This action will remove the following files from arXiv: -

    -
      -
    • {{ form.file_path.data }}
    • -
    - {{ form.file_path }} - {{ form.csrf_token }} - -
    - Keep these files - -
    -
    -
    -
    -
    -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete_all.html b/submit/templates/submit/confirm_delete_all.html deleted file mode 100644 index e21c20b..0000000 --- a/submit/templates/submit/confirm_delete_all.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "submit/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block title -%}Delete All Files{%- endblock title %} - -{% block within_content %} -
    - {{ form.csrf_token }} -
    -
    -
    -

    - This action will remove all of the files that you have uploaded. This - cannot be reversed! -

    - {{ form.csrf_token }} - -
    - Keep all files - -
    -
    -
    -
    -
    -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_delete_submission.html b/submit/templates/submit/confirm_delete_submission.html deleted file mode 100644 index 12dcaa7..0000000 --- a/submit/templates/submit/confirm_delete_submission.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "submit/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block title_preamble %}{% endblock %} -{% block title -%}{% if submission.version == 1 %}Delete Submission{% else %}Delete Replacement for Submission{% endif %} {{ submission.submission_id }}{%- endblock title %} - -{% block within_content %} -
    - {{ form.csrf_token }} -
    -
    -
    -
    -

    {% if submission.version == 1 %}Delete This Submission{% else %}Delete This Replacement{% endif %}

    -
    -
    -

    - {% if submission.version == 1 %} - Deleting will permanently remove all information entered and - uploaded for this submission from your account. Are you sure you - want to delete? - {% else %} - Deleting will revert your article to the most recently announced - version, and discard any new information entered or - uploaded during this replacement. Are you sure you want to delete? - {% endif %} -

    - {{ form.csrf_token }} - -
    - - No, keep working -
    -
    -
    -
    -
    -
    -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_submit.html b/submit/templates/submit/confirm_submit.html deleted file mode 100644 index a801872..0000000 --- a/submit/templates/submit/confirm_submit.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Submission Received{%- endblock title %} - -{% block within_content %} -
    -
    -
    -
    -

    - - Your submission has been successfully received.

    -

    Your submission time stamp is: {{ submission.submitted.strftime('%F, %T') }} (UTC).

    -

    All submissions are subject to moderation which may cause a delay in announcement in some cases.

    -

    If you need to make further changes click on "Manage my submission" below. To make changes you must first unsubmit your paper. Unsubmitting will remove your paper from the queue. Resubmitting will assign a new timestamp.

    -
    -
    -
    -
    - - - -{% endblock within_content %} diff --git a/submit/templates/submit/confirm_unsubmit.html b/submit/templates/submit/confirm_unsubmit.html deleted file mode 100644 index 2110bd9..0000000 --- a/submit/templates/submit/confirm_unsubmit.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Unsubmit This Submission{%- endblock title %} - -{% block within_content %} -
    - {{ form.csrf_token }} -
    -
    -
    -
    - {% block title_preamble %}{% endblock %} -
    -
    -

    - {#- TODO: include next announcement time as part of message. -#} - Unsubmitting will remove your article from the announcement - queue and prevent your article from being published. Are you sure - you want to unsubmit? -

    - {{ form.csrf_token }} - -
    - - Cancel -
    -
    -
    -
    -
    -
    - -{% if submission.version > 1 %} - {% set arxiv_id = submission.arxiv_id %} -{% else %} - {% set arxiv_id = "0000.00000" %} -{% endif %} - -
    -
    - {{ macros.abs( - arxiv_id, - submission.metadata.title, - submission.metadata.authors_display, - submission.metadata.abstract, - submission.created, - submission.primary_classification.category, - comments = submission.metadata.comments, - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = submission.metadata.journal_ref, - doi = submission.metadata.doi, - report_num = submission.metadata.report_num, - version = submission.version, - submission_history = submission_history, - secondary_categories = submission.secondary_categories) }} -
    -
    -{% endblock within_content %} diff --git a/submit/templates/submit/cross_list.html b/submit/templates/submit/cross_list.html deleted file mode 100644 index b8ad8a9..0000000 --- a/submit/templates/submit/cross_list.html +++ /dev/null @@ -1,89 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Choose Cross-List Categories (Optional){%- endblock title %} - -{% block within_content %} -

    You may cross-list your article to another relevant category here. - If accepted your article will appear in the regular listing for that category (in the cross-list section). - Cross-lists should be of direct interest to the professionals studying that field, not simply use techniques of, or derived from, that field. -

    -
    -
    -

    - Adding more than three cross-list classifications will result in a delay in the acceptance of your submission. - Readers consider excessive or inappropriate cross-listing to be bad etiquette. - It is rarely appropriate to add more than one or two cross-lists. - Note you are unlikely to know that a cross-list is appropriate unless you are yourself a reader of the archive which you are considering. -

    -
    -
    - -{% if formset.items %} -
    -

    (Optional) Add cross-list categories from the list below.

    - {% for category, subform in formset.items() %} - -
    - {{ subform.csrf_token }} -
      -
    1. - {{ subform.operation }} - {{ subform.category()|safe }} - {{ subform.category.data }} - {{ category|get_category_name }} - -
    2. -
    -
    - {% endfor %} -
    -{% endif %} - -
    -
    - {{ form.csrf_token }} - {{ form.operation }} - - {% with field = form.category %} - -
    -
    -
    - {% if field.errors %} - {{ field(class="is-danger")|safe }} - {% else %} - {{ field()|safe }} - {% endif %} -
    - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} -
    -
    - -
    -
    - {% endwith %} - -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/error_messages.html b/submit/templates/submit/error_messages.html deleted file mode 100644 index fc7567a..0000000 --- a/submit/templates/submit/error_messages.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Errors and Help{%- endblock title %} - -{% block within_content %} -
    - -
    -

    File Upload Errors

    -
    -
    file-has-unsupported-characters
    -
    File name contains unsupported characters. Renamed file_name to new_file_name. Check and modify any file paths that reference this file.
    -
    File names for arXiv may only contain a-z A-Z 0-9 . , - _ =. This file has been automatically renamed to a supported character set, but any references to this file in other documents are not automatically corrected.
    - -
    unable-to-rename
    -
    Unable to automatically rename file_name. Change file name and modify any file paths that reference this name.
    -
    File names for arXiv may only contain a-z A-Z 0-9 . , - _ =. This file name, and any references to this file within other documents in the upload, must be renamed with the allowed character set.
    - -
    file-starts-with-hyphen
    -
    File name starts with a hyphen. Renamed file_name to new_file_name. Check and modify any file paths that reference this file.
    -
    Hyphens are not allowed at the beginning of file names. This file has been automatically renamed to remove the hyphen, but any references to this file in other documents are not automatically corrected.
    - -
    hidden-file
    -
    Hidden file file_name has been detected and removed.
    -
    Files beginning with a . are not allowed and are automatically removed.
    - - -
    Hyperlink-compatible package package_name detected and removed. A local hypertex-compatible package name? will be used.
    -
    Styles that conflict with TeXLive 2016's internal hypertex package, such as espcrc2 and lamuphys, are removed. Instead, a local version of these style packages that are compatible with hypertext will be used.
    - -
    file-not-allowed
    -
    Conflicting file file_name detected and removed.
    -
    Files named `uufiles` or `core` or `splread.1st` will conflict with TeXLive 2016 and are automatically removed. These files re.search(r'^xxx\.(rsrc$|finfo$|cshrc$|nfs)', file_name) or re.search(r'\.[346]00gf$', file_name) or (re.search(r'\.desc$', file_name) and file_size < 10) are also automatically removed.
    - -
    bibtex
    -
    BibTeX file file_name removed. Please upload .bbl file instead.
    -
    We do not run BibTeX or store .bib files.
    - -
    file-already-included
    -
    File file_name is already included in TeXLive, removed from submission.
    -
    These files are included in TeXLive, and are removed from individual submissions. re.search(r'^(10pt\.rtx|11pt\.rtx|12pt\.rtx|aps\.rtx|revsymb\.sty|revtex4\.cls|rmp\.rtx)$', file_name)
    - -
    time-dependent-file
    -
    Time dependent package package_name detected and removed. Replaced with filename_internal.
    -
    This diagrams package stops working after a specified date. File is removed and replaced with an internal diagrams package that works for all time. re.search(r'^diagrams\.(sty|tex)$', file_name)
    - -
    aa-example-file
    -
    Example file aa.dem detected and removed.
    -
    Example files for the Astronomy and Astrophysics macro package aa.cls are not needed for any specific build, removed as unnecessary.
    - -
    name-conflict
    -
    Name conflict in file_name detected, file removed.
    -
    Naming conflicts are caused by files that are TeX-produced output, and can cause potential build corruption and/or conflicts.
    - -
    second-unsupported-character
    -
    File file_name contains unsupported character \"$&\". Renamed to new_file_name. Check and modify any path references to this file to avoid compilation errors.
    -
    Attempted fix for this already, would only see this error if renaming failed for some reason or there were multiple character errors in one file name.
    - -
    failed-doc
    -
    file_name.endswith('.doc') and type == 'failed': - # Doc warning - # TODO: Get doc warning from message class - msg = ''
    -
    no failed docs
    -
    - -

    File Upload Messages

    -
    -
    deleted-files
    -
    Deleting a file removes it permanently from a submission. It is important to delete any files that are not needed or used during TeX compilation or included in /anc for supplemental information.
    - -
    ancillary-files
    -
    Files that provide supporting content but are not part of a submission's primary document files are moved into an /anc directory. Checking the "Ancillary" box during upload will create an /anc directory and move uploaded files into that directory. To specify ancillary files in a single .tar upload, create the /anc directory and place ancillary files there. This prevents the TeX compiler from including files in the build that may slow or stop the compilation process and cause unwanted errors.
    -
    -
    -
    - -{% endblock within_content %} diff --git a/submit/templates/submit/file_process.html b/submit/templates/submit/file_process.html deleted file mode 100644 index 76655c7..0000000 --- a/submit/templates/submit/file_process.html +++ /dev/null @@ -1,281 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Process Files{%- endblock title %} - -{% block addl_head %} - {{ super() }} - {% if status and status == "in_progress" %} - - {% endif %} -{% endblock addl_head %} - - - -{% block more_notifications -%} -{% if status and status != "not_started" %} - {% if status == "in_progress" %} -
    -
    -

    - - Processing Underway -

    -
    -
    -

    We are currently processing your submission. This may take up to a minute or two. This page will refresh automatically every 5 seconds. You can also refresh this page manually to check the current status.

    -
    -
    - - {% elif status == "failed" and reason %} -
    -
    -

    - - {% if "docker" in reason %} - Processing Failed: There was an unrecoverable internal error in the compilation system. - {% elif 'produced from TeX source' in reason %} - Processing Failed: {{ reason }} - {% else %} - Processing Failed: - {% endif %} -

    -
    - {% if 'produced from TeX source' in reason %} -
    -

    What to do next:

    - -

    Go back to Upload Files

    -

    Help: Common TeX Errors

    -
    - - {% elif "docker" in reason %} -
    -

    What to do next:

    -
      -
    • There is no end-user solution to this system problem.
    • -
    • Please contact arXiv administrators. and provide the message above.
    • -
    -

    Go back to Upload Files

    -

    Help: Common TeX Errors

    -
    - {% else %} -
    -

    What to do next:

    -
      -
    • review the log warnings and errors
    • -
    • correct any missing file problems
    • -
    • edit your files if needed
    • -
    • re-upload any corrected files
    • -
    • process your upload after corrections
    • -
    -

    Go back to Upload Files

    -

    Help: Common TeX Errors

    -
    - {% endif %} -
    - - {% elif status == "failed" %} -
    -
    -

    - - Processing Failed -

    -
    -
    -

    What to do next:

    -
      -
    • review the log warnings and errors
    • -
    • correct any missing file problems. -
    • make sure links to included files match the names of the files exactly (it is case sensitive)
    • -
    • edit your files if needed
    • -
    • verify your references, citations and captions.
    • -
    • re-upload any corrected files
    • -
    • process your upload after corrections
    • -
    -

    Go back to Upload Files

    -

    Help: Common TeX Errors

    -
    -
    - - {% elif status == "succeeded" and warnings %} -
    -
    -

    - - Completed With Warnings -

    -
    -
    -

    Warnings may cause your submission to display incorrectly or be delayed in announcement.

    -
      -
    • Be sure to check the compiled preview carefully for any undesired artifacts or errors - especially references, figures, and captions.
    • -
    • Check the compiler log for warnings and errors.
    • -
    • Double check captions, references and citations.
    • -
    • For oversize submissions fully complete your submission first, then request an exception.
    • -
    -

    Help: TeX Rendering Problems

    -
    -
    - - {% elif status == "succeeded" %} -
    - -
    -

    - - Processing Successful -

    -
    -
    -

    Be sure to check the compiled preview carefully for any undesired artifacts or errors.

    -

    If you need to make corrections, return to the previous step and adjust your files, then reprocess your submission.

    -

    - Avoid common causes of delay: - Double check captions, references and citations. - For oversize submissions fully complete your submission first, then request an exception. -

    - -
    -
    - {% endif %} -{% endif %} -{%- endblock more_notifications %} - - - -{% block within_content %} -
    - {{ form.csrf_token }} - -
    -
    - - {% if not status or status == "not_started" %} -

    This step runs all files through a series of automatic checks and compiles TeX and LaTeX source packages into a PDF. Learn more about our compiler and included packages.

    -
    -
    - - -
    -
    - {% endif %} - - {% if status and status != "not_started" %} - {% if status == "in_progress" %} -

    Files are processing...

    - {% elif status == "failed" %} -

    File processing has failed. Click below to go back to the previous page:

    -

    Go back to Upload Files

    -

    - {% elif status == "succeeded" and warnings %} -

    All files have been processed with some warnings.

    - {% elif status == "succeeded" %} -

    All files have been processed successfully. Preview your article PDF before continuing.

    -

    - View PDF - -

    - {% endif %} - - {% endif %} - - {% if status and log_output %} -

    - Check the compiler log summary for any warnings and errors. - View the raw log for further details. -

    -
    -

    Summary Highlight Key

    - Severe warnings/errors
    - Important warnings
    - General warnings/errors from packages
    - Unimportant warnings/errors
    - Positive event
    - Informational markup
    - References to help documentation
    - Recommended solution
    -
    -
    - - View raw log - - - {% endif %} - - {% if status and status != "not_started" %} -
    - - {% endif%} -
    - -
    - {% if status and log_output %} -
    -
    -
    -

    TeXLive Compiler Summary

    -
    - -
    -
    - {{ log_output | compilation_log_display(submission.submission_id, status) | replace("\n", "
    ") | safe }} -
    - -
    - {% endif %} -
    -
    - -{{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/file_upload.html b/submit/templates/submit/file_upload.html deleted file mode 100644 index cb72246..0000000 --- a/submit/templates/submit/file_upload.html +++ /dev/null @@ -1,178 +0,0 @@ -{% extends "submit/base.html" %} - -{% block addl_head %} -{{super()}} - -{% endblock addl_head %} - -{% macro display_tree(key, item, even) %} - {% if key %} -
  • - {% endif %} - {% if item.name %} -
    -
    - {{ item.name }} -
    -
    -
    -
    {{ item.size|filesizeformat }}
    -
    {{ item.modified|timesince }}
    - -
    -
    -
    - {% else %} - {% if key %} - - {{ key }}/{% endif %} -
      - {% for k, subitem in item.items() %} - {{ display_tree(k, subitem, loop.cycle('', 'even')) }} - {% endfor %} -
    - {% endif %} - {% if item.errors %} - {% for error in item.errors %} -

    - {{ error.message }} -

    - {% endfor %} - {% endif %} - {% if key %} -
  • - {% endif %} -{% endmacro %} - -{% block title -%}Upload Files{%- endblock title %} - - -{% if immediate_notifications %} - {% for notification in immediate_notifications %} - - {% endfor %} -{% endif %} - - -{% block within_content %} -

    TeX and (La)TeX submissions are highly encouraged. This format is the most likely to retain readability and high-quality output in the future. TeX source uploaded to arXiv will be made publicly available.

    -
    -
    - -
    - {{ form.csrf_token }} -
    -
    -
    - -
    -
    - -
    - -
    -
    - {% if form.file.errors %} - {% for error in form.file.errors %} -

    {{ error }}

    - {% endfor %} - {% endif %} -

    - You can upload all your files at once as a single .zip or .tar.gz file, or upload individual files as needed. -

    - Avoid common causes of delay: Make sure included files match the filenames exactly (it is case sensitive), - and verify your references, citations and captions. -

    - {% if status and status.errors %} - {% for error in status.errors %} -

    - {{ error.message }} -

    - {% endfor %} - {% endif %} - - {% if status and status.files %} -

    Files currently attached to this submission ({{ status.size|filesizeformat }})

    - - {{ display_tree(None, status.files|group_files) }} - -

    - - Remove All - - -

    - {% elif error %} -

    - Something isn't working right now. Please try again. -

    - {% else %} -

    - No files have been uploaded yet. -

    - {% endif %} -
    - -
    -
    -
    -

    - - Accepted formats, in order of preference -

    - -

    - - Accepted formats for figures -

    -
      -
    • (La)TeX: Postscript
    • -
    • PDFLaTeX: JPG, GIF, PNG, or PDF
    • -
    -

    - - Accepted file properties -

    - -
    -
    -
    - -
    -{{ submit_macros.submit_nav(submission_id) }} -
    - -{% endblock within_content %} diff --git a/submit/templates/submit/final_preview.html b/submit/templates/submit/final_preview.html deleted file mode 100644 index 344eba6..0000000 --- a/submit/templates/submit/final_preview.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Review and Approve Your Submission{%- endblock title %} - -{% block within_content %} -
    -
    -
    -
    -

    Review your submission carefully! - Editing your submission after clicking "confirm and submit" will remove your paper from the queue. - A new timestamp will be assigned after resubmitting.

    -

    Note that a citation link with your final arxiv identifier is not available until after the submission is accepted and published.

    -

    Once your submission has been announced, amendments to files or core metadata may only be made through - replacement or withdrawal. - Exception: You will be able to update journal reference, DOI, MSC or ACM classification, or report number information at any time without a new revision.

    -
    -
    -
    -
    - -{% if submission.version > 1 %} - {% set arxiv_id = submission.arxiv_id %} -{% else %} - {% set arxiv_id = "0000.00000" %} -{% endif %} - -
    -{{ macros.abs( - arxiv_id, - submission.metadata.title, - submission.metadata.authors_display|tex2utf, - submission.metadata.abstract, - submission.created, - submission.primary_classification.category, - comments = submission.metadata.comments, - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = submission.metadata.journal_ref, - doi = submission.metadata.doi, - report_num = submission.metadata.report_num, - version = submission.version, - submission_history = submission_history, - secondary_categories = submission.secondary_categories) }} -
    - - -
    - - - -
    - {{ form.csrf_token }} - {% if form.proceed.errors %}
    {% endif %} -
    -
    -
    - {{ form.proceed }} - {{ form.proceed.label }} -
    - {% for error in form.proceed.errors %} -

    {{ error }}

    - {% endfor %} -
    -
    - {% if form.proceed.errors %}
    {% endif %} -
    - - -
    -{% endblock within_content %} diff --git a/submit/templates/submit/jref.html b/submit/templates/submit/jref.html deleted file mode 100644 index 24245ae..0000000 --- a/submit/templates/submit/jref.html +++ /dev/null @@ -1,161 +0,0 @@ -{%- extends "base/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block addl_head %} - - -{% endblock addl_head %} - -{% block content %} -

    Update Journal Reference, DOI, or Report Number

    - {% if require_confirmation and not confirmed %} -
    -

    This preview shows the abstract page as it will be updated. - Please check carefully to ensure that it is correct.
    - You can continue to make changes until you are - satisfied with the result.

    -
    - -
    - {{ macros.abs( - submission.arxiv_id, - submission.metadata.title, - submission.metadata.authors_display, - submission.metadata.abstract, - submission.created, - submission.primary_classification.category, - comments = submission.metadata.comments, - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = form.journal_ref.data, - doi = form.doi.data, - report_num = form.report_num.data, - version = submission.version, - submission_history = [], - secondary_categories = submission.secondary_categories) }} -
    - {% else %} -

    arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    - {% endif %} - - -
    -
    -
    - {{ form.csrf_token }} - {% with field = form.journal_ref %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.doi %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} - - {% with field = form.report_num %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="input is-danger")|safe }} - {% else %} - {{ field(class="input")|safe }} - {% endif %} -
    -
    - {% endwith %} -
    -
    - -
    -
    - - - -
    - -{% endblock content %} diff --git a/submit/templates/submit/license.html b/submit/templates/submit/license.html deleted file mode 100644 index c6d2772..0000000 --- a/submit/templates/submit/license.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Select a License{%- endblock title %} - -{% block within_content %} - -
    - {{ form.csrf_token }} -
    -

    Submission license details - Scroll to read before selecting a license

    -
    -
    -
    -

    This license is irrevocable and permanent. If you plan to publish this article in a journal, check the journal's policies before uploading to arXiv.

    -
    -
    -

    The Submitter is an original author of the Work, or a proxy pre-approved by arXiv administrators acting on behalf of an original author. By submitting the work to arXiv, the Submitter is:

    -
      -
    1. Representing and warranting that the Submitter holds the right to make the submission, without conflicting with or violating rights of other persons.
    2. -
    3. Granting a license permitting the work to be included in arXiv.
    4. -
    5. Agreeing not to sue or seek to recover damages from arXiv, Cornell University, or affiliated individuals based on arXiv's actions in connection with your submission including refusal to accept, categorization, removal, or exercise of any other rights granted under the Submittal Agreement.
    6. -
    7. Agreeing to not enter into other agreements or make other commitments that would conflict with the rights granted to arXiv.
    8. -
    -

    arXiv is a repository for scholarly material, and perpetual access is necessary to maintain the scholarly record. Therefore, arXiv strives to maintain a permanent collection of submitted works, including actions taken with respect to each work. Nevertheless, arXiv reserves the right, in its sole discretion, to take any action including to removal of a work or blocking access to it, in the event that arXiv determines that such action is required in order to comply with applicable arXiv policies and laws. Submitters should take care to submit a work to arXiv only if they are certain that they will not later wish to publish it in a journal or other outlet that prohibits prior distribution on an e-print server. arXiv will not remove an announced article in order to comply with such a journal policy – the license granted on submission is irrevocable.

    -

    arXiv is a service of Cornell University, and all rights and obligations of arXiv are held and exercised by Cornell University.

    -

    If you have any additional questions about arXiv’s copyright and licensing policies, please contact the arXiv administrators directly.

    -
    -
    -
    -
    -
    -

    Select Licence

    - {% if form.license.errors %}
    {% endif %} -
    - Select a license for your submission - {% for subfield in form.license %} -
    - -
    - {% endfor %} -
    - {% if form.license.errors %} - {% for error in form.license.errors %} -

    {{ error }}

    - {% endfor %} - {% endif %} - {% if form.license.errors %}
    {% endif %} -
    -
    -
    -
    -

    Not sure which license to select?

    -

    See the Discussion of licenses for more information.

    -

    If you wish to use a different CC license, then select arXiv's non-exclusive license to distribute in the arXiv and indicate the desired Creative Commons license in the article's full text.

    -
    -
    -
    -
    -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/manage_submissions.html b/submit/templates/submit/manage_submissions.html deleted file mode 100644 index fef208e..0000000 --- a/submit/templates/submit/manage_submissions.html +++ /dev/null @@ -1,175 +0,0 @@ -{% extends "submit/base.html" %} - -{% block extra_head %}{% endblock %} - -{% block title_preamble %}{% endblock %} -{% block title %}Manage Submissions{% endblock %} - -{% block within_content %} -
    -
    -
    - -

    Welcome [First Name]

    -

    Click to start a new submission

    -
    - {{ form.csrf_token }} - -
    -

    Review arXiv's extensive submission documentation

    - Submission Help Pages
    -

    Need to make changes to your account before submitting? Manage account info here

    - Manage Account -
    -
    -
    - -
    -
    - -
    -
    -
    -

    Submissions in Progress

    -
    -
    -

    Only submissions started with this interface are listed. - View classic list

    -
    -
    -
    - {% if user_submissions|selectattr('is_active')|list|length > 0 %} - - - - - - - - - - - {% for submission in user_submissions %} - {% if not submission.is_announced %} - - - - - - - - {% endif %} - {% endfor %} -
    Status - - - - Read status descriptions -
    The current status of your submission in arXiv moderation. Click to read all status descriptinos.
    -
    -
    IdentifierTitleCreatedActions
    {{ submission.status }}submit/{{ submission.submission_id }}{% if submission.metadata.title %} - {{ submission.metadata.title }} - {% else %} - Submission {{ submission.submission_id }} - {% endif %}{{ submission.created|timesince }} - {% if submission.status == submission.SUBMITTED and not submission.has_active_requests %} - - Unsubmit - Unsubmit submission id {{ submission.submission_id }} -
    Warning! Unsubmitting your paper will remove it from the publishing queue.
    -
    - {% else %} - - - Edit submission id {{ submission.submission_id }} - - {% endif %} - - Delete - Delete submission id {{ submission.submission_id }} - -
    - {% else %} -

    No submissions currently in progress

    - {% endif %} -
    -
    - -
    -

    Announced Articles

    -
    - {% if user_submissions|selectattr('is_announced')|list|length > 0 %} - - - - - - - - - - {% for submission in user_submissions %} - {% if submission.is_announced %} - - - - - - - {% endif %} - {% endfor %} -
    IdentifierPrimary ClassificationTitleActions
    {{ submission.arxiv_id }}{{ submission.primary_classification.category }}{{ submission.metadata.title }} - {% if submission.has_active_requests %} - {% for request in submission.active_user_requests %} -
    - {{ request.NAME }} {{ request.status }} - - Delete - Delete request id {{ request.request_id }} - -
    - {% endfor %} - {% else %} - - - - {% endif %} -
    - {% else %} -

    No announced articles

    - {% endif %} -
    -
    - -{% endblock %} diff --git a/submit/templates/submit/policy.html b/submit/templates/submit/policy.html deleted file mode 100644 index 7aa10e0..0000000 --- a/submit/templates/submit/policy.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Acknowledge Policy Statement{%- endblock title %} - -{% block within_content %} -
    -
    -

    Terms and Conditions - Scroll to read before proceeding

    -
    -

    Any person submitting a work for deposit in arXiv is required to assent to the following terms and conditions.

    - -

    Representations and Warranties

    -

    The Submitter makes the following representations and warranties:

    -
      -
    • The Submitter is an original author of the Work, or a proxy pre-approved by arXiv administrators acting on behalf of an original author.
    • -
    • If the Work was created by multiple authors, that all of them have consented to the submission of the Work to arXiv.
    • -
    • The Submitter has the right to include any third-party materials used in the Work.
    • -
    • The use of any third-party materials is consistent with scholarly and academic standards of proper citation and acknowledgement of sources.
    • -
    • The Work is not subject to any agreement, license or other claim that could interfere with the rights granted herein.
    • -
    • The distribution of the Work by arXiv will not violate any rights of any person or entity, including without limitation any rights of copyright, patent, trade secret, privacy, or any other rights, and it contains no defamatory, obscene, or other unlawful matter.
    • -
    • The Submitter has authority to make the statements, grant the rights, and take the actions described above.
    • -
    - -

    Grant of the License to arXiv

    -

    The Submitter grants to arXiv upon submission of the Work a non-exclusive, perpetual, and royalty-free license to include the Work in the arXiv repository and permit users to access, view, and make other uses of the work in a manner consistent with the services and objectives of arXiv. This License includes without limitation the right for arXiv to reproduce (e.g., upload, backup, archive, preserve, and allow online access), distribute (e.g., allow access), make available, and prepare versions of the Work (e.g., , abstracts, and metadata or text files, formats for persons with disabilities) in analog, digital, or other formats as arXiv may deem appropriate. - -

    Waiver of Rights and Indemnification

    -

    The Submitter waives the following claims on behalf of him/herself and all other authors:

    -
      -
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof, based upon the use of the Work by arXiv consistent with the License.
    • -
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof, based upon actions taken by any such party with respect to the Work, including without limitation decisions to include the Work in, or exclude the Work from, the repository; editorial decisions or changes affecting the Work, including the identification of Submitters and their affiliations or titles; the classification or characterization of the Work; the content of any metadata; the availability or lack of availability of the Work to researchers or other users of arXiv, including any decision to include the Work initially or remove or disable access.
    • -
    • Any rights related to the integrity of the Work under Moral Rights principles.
    • -
    • Any claims against arXiv or Cornell University, or any officer, employee, or agent thereof based upon any loss of content or disclosure of information provided in connection with a submission.
    • -
    • The Submitter will indemnify, defend, and hold harmless arXiv, Cornell University and its officers, employees, agents, and other affiliated parties from any loss, damage, or claim arising out of or related to: (a) any breach of any representations or warranties herein; (b) any failure by Submitter to perform any of Submitter’s obligations herein; and (c) any use of the Work as anticipated in the License and terms of submittal.
    • -
    -

    Management of Copyright

    -

    This grant to arXiv is a non-exclusive license and is not a grant of exclusive rights or a transfer of the copyright. The Submitter (and any other authors) retains their copyright and may enter into publication agreements or other arrangements, so long as they do not conflict with the ability of arXiv to exercise its rights under the License. arXiv has no obligation to protect or enforce any copyright in the Work, and arXiv has no obligation to respond to any permission requests or other inquiries regarding the copyright in or other uses of the Work.

    - -

    The Submitter may elect to make the Work available under one of the following Creative Commons licenses that the Submitter shall select at the time of submission: -

      -
    • Creative Commons Attribution license (CC BY 4.0)
    • -
    • Creative Commons Attribution-ShareAlike license (CC BY-SA 4.0)
    • -
    • Creative Commons Attribution-Noncommercial-ShareAlike license (CC BY-NC-SA 4.0)
    • -
    • Creative Commons Public Domain Dedication (CC0 1.0)
    • -
    - -

    If you wish to use a different CC license, then select arXiv's non-exclusive license to distribute in the arXiv submission process and indicate the desired Creative Commons license in the actual article. - The Creative Commons licenses are explained here:
    - https://creativecommons.org/licenses/.

    - -

    Metadata license

    -

    To the extent that the Submitter or arXiv has a copyright interest in metadata accompanying the submission, a Creative Commons CC0 1.0 Universal Public Domain Dedication will apply. Metadata includes title, author, abstract, and other information describing the Work.

    - -

    General Provisions

    -

    This Agreement will be governed by and construed in accordance with the substantive and procedural laws of the State of New York and the applicable federal law of the United States without reference to any conflicts of laws principles. The Submitter agrees that any action, suit, arbitration, or other proceeding arising out of or related to this Agreement must be commenced in the state or federal courts serving Tompkins County, New York. The Submitter hereby consents on behalf of the Submitter and any other authors to the personal jurisdiction of such courts.

    -
    - - -
    -
    - {{ form.csrf_token }} - {% if form.policy.errors %}
    {% endif %} -
    -
    -
    - {{ form.policy }} - {{ form.policy.label }} -
    - {% for error in form.policy.errors %} -

    {{ error }}

    - {% endfor %} -
    -
    - {% if form.policy.errors %}
    {% endif %} -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/replace.html b/submit/templates/submit/replace.html deleted file mode 100644 index e0b9c35..0000000 --- a/submit/templates/submit/replace.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "submit/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block content %} -

    Start a New Replacement

    - -{% if submission and submission.version >= 1 %} -

    Replacing arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    -{% endif %} - - - -

    Considerations when replacing an article:

    -
      -
    • You may use the Comments field to inform readers about the reason for the replacement.
    • -
    • A new version of the article will be generated.
    • -
    • After the fifth revision, papers will not be announced in the daily mailings.
    • -
    - -

    You can add a DOI, journal reference, or report number without submitting a replacement if that is the only change being made. To do so, cancel this replacement and submit a journal reference.

    - -
    - {{ form.csrf_token }} - - -
    - -{% endblock content %} diff --git a/submit/templates/submit/request_cross_list.html b/submit/templates/submit/request_cross_list.html deleted file mode 100644 index c292b64..0000000 --- a/submit/templates/submit/request_cross_list.html +++ /dev/null @@ -1,121 +0,0 @@ -{%- extends "base/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{% block addl_head %} - - -{% endblock addl_head %} - -{% block content %} -

    Request Cross-List Classification

    -

    {% if primary %} - {{ submission.primary_classification.category }} - {% endif %} - {% for category in submission.secondary_categories %} - {{ category }} - {% endfor %} - arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    - -
    -
    -

    New cross-list(s)

    - {# This formset is used to send POST requests to REMOVE secondaries from the list. #} - {% if formset %} - {% for category, subform in formset.items() %} - -
    - {{ form.csrf_token }} -

    - {{ subform.operation }} - {% for cat in selected %} - - {% endfor %} - {{ subform.category()|safe }} - {{ subform.category.data }} {{ category|get_category_name }} - -

    -
    - {% endfor %} - {% endif %} - - {# This form is used to send POST requests to ADD secondaries to the list. #} -
    - {{ form.csrf_token }} - {{ form.operation }} - {% for cat in selected %} - - {% endfor %} - {% with field = form.category %} -
    -
    -
    - {% if field.errors %} - {{ field(class="is-danger")|safe }} - {% else %} - {{ field(**{"aria-labelledby": "crosslist"})|safe }} - {% endif %} -
    - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} -
    -
    - -
    -
    - {% endwith %} -
    -
    - -
    - -
    -
    - -{% endblock content %} diff --git a/submit/templates/submit/status.html b/submit/templates/submit/status.html deleted file mode 100644 index 8d6c777..0000000 --- a/submit/templates/submit/status.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}{{ submission.status }}{%- endblock title %} - -{% block within_content %} -

    - This is for dev/debugging. We need to implement this page with a real - layout that communicates what's going on with a user's submission. -

    - -
    -{{ submission|asdict|pprint }}
    -
    - -{% for event in events %} -
  • - {{ event.NAMED.capitalize() }} {{ event.created|timesince }} ({{event.event_id}}) -
    -  {{ event|asdict|pprint }}
    -  
    -
  • -{% endfor %} - -{% endblock within_content %} diff --git a/submit/templates/submit/submit_macros.html b/submit/templates/submit/submit_macros.html deleted file mode 100644 index 57f0acf..0000000 --- a/submit/templates/submit/submit_macros.html +++ /dev/null @@ -1,41 +0,0 @@ -{% macro submit_nav(submission_id) %} - - - -{% endmacro %} - -{% macro progress_bar(submission_id, workflow, this_stage) %} - -{% endmacro %} diff --git a/submit/templates/submit/testalerts.html b/submit/templates/submit/testalerts.html deleted file mode 100644 index 5648556..0000000 --- a/submit/templates/submit/testalerts.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "submit/base.html" %} diff --git a/submit/templates/submit/tex-log-test.html b/submit/templates/submit/tex-log-test.html deleted file mode 100644 index 783237f..0000000 --- a/submit/templates/submit/tex-log-test.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - -

    Testing Document

    -

    This is a snippet of TeX log done in plain old HTML, to very rapidly test CSS for log highlighting. It is also self-documenting for the list of terms to highlight. Can be removed if there is no use for it in also testing regexes or other log functions.

    -
    -

    Terms to highlight with info:
    - ~~ (*) ~~ /~+.*~+/g -

    -

    Terms to highlight with warning, case insensitive:
    - Warning
    - Citation (*) undefined
    - No (*) file
    - Unsupported
    - Unable
    - Ignore
    - Undefined
    -

    -

    Terms to highlight with danger, case insensitive:
    - !(any number repeating) or !==> /!+(==>)?/g
    - Error or [error] /\[?\berror\b\]?/gi
    - Failed
    - Emergency stop
    - File (*) not found
    - Not allowed
    - Does not exist
    -

    -

    Terms to highlight with fatal, case insensitive:
    - Fatal
    - Fatal (*) error /\bfatal\b[\W\w]*?\berror\b/gi
    -

    -
    -

    - [verbose]: pdflatex 'main.tex' failed.
    - [verbose]: 'htex' is not a valid TeX format; will ignore.
    - [verbose]: TEXMFCNF is unset.
    - [verbose]: ~~~~~~~~~~~ Running htex for the first time ~~~~~~~~
    - [verbose]: Running: "(export HOME=/tmp PATH=/texlive/2016/bin/arch:/bin; cd /submissions/2409425/ && tex 'main.tex' < /dev/null)" 2>&1
    - [verbose]: This is TeX, Version 3.14159265 (TeX Live 2016) (preloaded format=tex)
    - (./main.tex
    - ! Undefined control sequence.
    - l.1 \documentclass
    - [runningheads,a4paper]{llncs}
    - ?
    - ! Emergency stop.
    - l.1 \documentclass
    - [runningheads,a4paper]{llncs}
    - No pages of output.
    - Transcript written on main.log.
    -
    - [verbose]: tex 'main.tex' failed.
    - [verbose]: TEXMFCNF is unset.
    - [verbose]: ~~~~~~~~~~~ Running tex for the first time ~~~~~~~~
    - [verbose]: Running: "(export HOME=/tmp PATH=/texlive/2016/bin/arch:/bin; cd /submissions/2409425/ && tex 'main.tex' < /dev/null)" 2>&1
    - [verbose]: This is TeX, Version 3.14159265 (TeX Live 2016) (preloaded format=tex)
    - (./main.tex
    - ! Undefined control sequence.
    - l.1 \documentclass
    - [runningheads,a4paper]{llncs}
    - ?
    - ! Emergency stop.
    - l.1 \documentclass
    - [runningheads,a4paper]{llncs}
    - No pages of output.
    - Transcript written on main.log.
    -
    - [verbose]: Fatal error - [verbose]: tex 'main.tex' failed.
    - [verbose]: We failed utterly to process the TeX file 'main.tex'
    - [error]: Unable to sucessfully process tex files.
    - *** AutoTeX ABORTING ***
    -
    - [verbose]: AutoTeX returned error: Unable to sucessfully process tex files. -

    - - diff --git a/submit/templates/submit/verify_user.html b/submit/templates/submit/verify_user.html deleted file mode 100644 index 2410dae..0000000 --- a/submit/templates/submit/verify_user.html +++ /dev/null @@ -1,97 +0,0 @@ -{% extends "submit/base.html" %} - -{% block title -%}Verify Your Contact Information{%- endblock title %} - -{% block more_notifications -%} - {% if submitter.endorsements|endorsetype == 'Some' %} -
    -
    -

    Endorsements

    -
    -
    -
    - -
    -

    You are currently endorsed for:

    -
      - {% for endorsement in submitter.endorsements %} -
    • {{ endorsement.display }}
    • - {% endfor %} -
    -

    If you wish to submit to a category other than those listed, you will need to seek endorsement before submitting.

    -
    -
    - {% endif %} - {% if submitter.endorsements|endorsetype == 'None' %} -
    -
    -

    Endorsements

    -
    -
    -
    - -
    -

    Your account does not currently have any endorsed categories. You will need to seek endorsement before submitting.

    -
    -
    - {% endif %} -{%- endblock more_notifications %} - -{% block within_content %} -

    Check this information carefully! A current email address is required to complete your submission. The name and email address of the submitter will be viewable to registered arXiv users.

    - - -
    - {{ form.csrf_token }} -
    - {% if form.verify_user.errors %}
    {% endif %} -
    -
    - {{ form.verify_user }} - {{ form.verify_user.label }} -
    - {% for error in form.verify_user.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% if form.verify_user.errors %}
    {% endif %} -
    - {{ submit_macros.submit_nav(submission_id) }} -
    -{% endblock within_content %} diff --git a/submit/templates/submit/withdraw.html b/submit/templates/submit/withdraw.html deleted file mode 100644 index 8715fc8..0000000 --- a/submit/templates/submit/withdraw.html +++ /dev/null @@ -1,124 +0,0 @@ -{%- extends "base/base.html" %} - -{% import "submit/submit_macros.html" as submit_macros %} - -{%- macro comments_preview(comments, withdrawal_reason) -%} -{{ comments }} Withdrawn: {{ withdrawal_reason}} -{% endmacro %} - -{% block addl_head %} - - -{% endblock addl_head %} - -{% block content %} -

    Request Article Withdrawal

    -

    arXiv:{{ submission.arxiv_id }}v{{ submission.version }} {{ submission.metadata.title }}

    - - {% if require_confirmation and not confirmed %} -
    -

    This preview shows the abstract page as it will be updated. Please - check carefully to ensure that it is correct. You can continue to - make changes until you are satisfied with the result.

    -
    - -
    - {{ macros.abs( - submission.arxiv_id, - submission.metadata.title, - submission.metadata.authors_display, - submission.metadata.abstract, - submission.created, - submission.primary_classification.category, - comments = comments_preview(submission.metadata.comments, form.withdrawal_reason.data), - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = submission.metadata.journal_ref, - doi = submission.metadata.doi, - report_num = submission.metadata.report_num, - version = submission.version, - submission_history = [], - secondary_categories = submission.secondary_categories) }} -
    - {% else %} - - -

    Considerations for making a withdrawal request for an article:

    -
      -
    1. The paper cannot be completely removed. Previous versions will remain accessible.
      See help pages on: withdrawals, versions and licenses.
    2. -
    3. Inappropriate withdrawal requests will be denied.
    4. -
    5. It is not appropriate to withdraw a paper because it is published or submitted to a journal. Instead you could submit a journal-ref.
    6. -
    7. It is not appropriate to withdraw a paper because it is being updated. Instead you could submit a replacement.
    8. -
    9. You may modify the abstract field only if the comments field is inadequate for your explanation. Removing the abstract totally is inappropriate and will result in a denial of your withdrawal request.
    10. -
    - {% endif %} - -
    -
    -
    - {{ form.csrf_token }} - {% with field = form.withdrawal_reason %} -
    -
    - - {% if field.errors %} -
    - {% for error in field.errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - {% if field.description %} -

    - {{ field.description|safe }} -

    - {% endif %} - {% if field.errors %} - {{ field(class="textarea is-danger")|safe }} - {% else %} - {{ field(class="textarea")|safe }} - {% endif %} -
    -
    - {% endwith %} - -
    -
    - -
    -
    - -
    - -{% endblock content %} diff --git a/submit/tests/__init__.py b/submit/tests/__init__.py deleted file mode 100644 index ad04188..0000000 --- a/submit/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the application as a whole.""" diff --git a/submit/tests/csrf_util.py b/submit/tests/csrf_util.py deleted file mode 100644 index 2a15bdf..0000000 --- a/submit/tests/csrf_util.py +++ /dev/null @@ -1,24 +0,0 @@ -import re - -CSRF_PATTERN = (r'\') - - -def parse_csrf_token(input): - """Gets the csrf token from a WTForm. - - This can usually be passed back to the web app as the field 'csrf_token' """ - try: - txt = None - if hasattr(input, 'text'): - txt = input.text - elif hasattr(input, 'data'): - txt = input.data.decode('utf-8') - else: - txt = input - - - return re.search(CSRF_PATTERN, txt).group(1) - except AttributeError: - raise Exception('Could not find CSRF token') - return token diff --git a/submit/tests/mock_filemanager.py b/submit/tests/mock_filemanager.py deleted file mode 100644 index 9cf9693..0000000 --- a/submit/tests/mock_filemanager.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Mock file management app for testing and development.""" - -from datetime import datetime -import json -from flask import Flask, Blueprint, jsonify, request -from werkzeug.exceptions import RequestEntityTooLarge, BadRequest, \ - Unauthorized, Forbidden, NotFound - -from http import HTTPStatus as status - -blueprint = Blueprint('filemanager', __name__) - -UPLOADS = { - 1: dict( - checksum='a1s2d3f4', - size=593920, - files={ - 'thebestfile.pdf': dict( - path='', - name='thebestfile.pdf', - file_type='PDF', - modified=datetime.now().isoformat(), - size=20505, - ancillary=False, - errors=[] - ) - }, - errors=[] - ), - 2: dict(checksum='4f3d2s1a', size=0, files={}, errors=[]) -} - - -def _set_upload(upload_id, payload): - upload_status = dict(payload) - upload_status['files'] = { - f'{f["path"]}{f["name"]}': f for f in upload_status['files'] - } - UPLOADS[upload_id] = upload_status - return _get_upload(upload_id) - - -def _get_upload(upload_id): - try: - status = dict(UPLOADS[upload_id]) - except KeyError: - raise NotFound('Nope') - if type(status['files']) is dict: - status['files'] = list(status['files'].values()) - status['identifier'] = upload_id - return status - - -def _add_file(upload_id, file_data): - UPLOADS[upload_id]['files'][f'{file_data["path"]}{file_data["name"]}'] = file_data - return _get_upload(upload_id) - - -@blueprint.route('/status') -def service_status(): - """Mock implementation of service status route.""" - return jsonify({'status': 'ok'}) - - -@blueprint.route('/', methods=['POST']) -def upload_package(): - """Mock implementation of upload route.""" - if 'file' not in request.files: - raise BadRequest('No file') - content = request.files['file'].read() - if len(content) > 80000: # Arbitrary limit. - raise RequestEntityTooLarge('Nope!') - if 'Authorization' not in request.headers: - raise Unauthorized('Nope!') - if request.headers['Authorization'] != '!': # Arbitrary value. - raise Forbidden('No sir!') - - payload = json.loads(content) # This is specific to the mock. - # Not sure what the response will look like yet. - upload_id = max(UPLOADS.keys()) + 1 - upload_status = _set_upload(upload_id, payload) - return jsonify(upload_status), status.CREATED - - -@blueprint.route('/', methods=['POST']) -def add_file(upload_id): - """Mock implementation of file upload route.""" - upload_status = _get_upload(upload_id) - if 'file' not in request.files: - raise BadRequest('{"error": "No file"}') - content = request.files['file'].read() - if len(content) > 80000: # Arbitrary limit. - raise RequestEntityTooLarge('{"error": "Nope!"}') - if 'Authorization' not in request.headers: - raise Unauthorized('{"error": "No chance"}') - if request.headers['Authorization'] != '!': # Arbitrary value. - raise Forbidden('{"error": "No sir!"}') - - # Not sure what the response will look like yet. - payload = json.loads(content) - upload_status = _add_file(upload_id, payload) - return jsonify(upload_status), status.CREATED - - -def create_fm_app(): - """Generate a mock file management app.""" - app = Flask('filemanager') - app.register_blueprint(blueprint) - return app diff --git a/submit/tests/test_domain.py b/submit/tests/test_domain.py deleted file mode 100644 index 6f624d9..0000000 --- a/submit/tests/test_domain.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for the ui-app UI domain classes.""" - -from unittest import TestCase, mock -from datetime import datetime -from arxiv.submission.domain import Submission, User -# Commenting out for now - there is nothing that runs below - everything -# is commented out - dlf2 -#from .. import domain - - -# class TestSubmissionStage(TestCase): -# """Tests for :class:`domain.SubmissionStage`.""" -# -# def test_initial_stage(self): -# """Nothing has been done yet.""" -# ui-app = Submission( -# creator=User('12345', 'foo@user.edu'), -# owner=User('12345', 'foo@user.edu'), -# created=datetime.now() -# ) -# submission_stage = domain.SubmissionStage(ui-app) -# self.assertEqual(submission_stage.current_stage, None, -# "No stage is complete.") -# self.assertEqual(submission_stage.next_stage, -# domain.SubmissionStage.ORDER[0][0], -# "The next stage is the first stage.") -# self.assertIsNone(submission_stage.previous_stage, -# "There is no previous stage.") -# -# self.assertTrue( -# submission_stage.before(domain.Stages.POLICY) -# ) -# self.assertTrue( -# submission_stage.on_or_before(domain.Stages.POLICY) -# ) -# self.assertTrue( -# submission_stage.on_or_before(submission_stage.current_stage) -# ) -# self.assertFalse( -# submission_stage.after(domain.Stages.POLICY) -# ) -# self.assertFalse( -# submission_stage.on_or_after(domain.Stages.POLICY) -# ) -# self.assertTrue( -# submission_stage.on_or_after(submission_stage.current_stage) -# ) -# -# def test_verify(self): -# """The user has verified their information.""" -# ui-app = Submission( -# creator=User('12345', 'foo@user.edu'), -# owner=User('12345', 'foo@user.edu'), -# created=datetime.now(), -# submitter_contact_verified=True -# ) -# submission_stage = domain.SubmissionStage(ui-app) -# self.assertEqual(submission_stage.previous_stage, None, -# "There is nothing before the verify user stage") -# self.assertEqual(submission_stage.next_stage, -# domain.Stages.AUTHORSHIP, -# "The next stage is to indicate authorship.") -# self.assertEqual(submission_stage.current_stage, -# domain.Stages.VERIFY_USER, -# "The current completed stage is verify user.") diff --git a/submit/tests/test_workflow.py b/submit/tests/test_workflow.py deleted file mode 100644 index b9799c2..0000000 --- a/submit/tests/test_workflow.py +++ /dev/null @@ -1,762 +0,0 @@ -"""Tests for the ui-app application as a whole.""" - -import os -import re -import tempfile -from unittest import TestCase, mock -from urllib.parse import urlparse - -from arxiv_auth.helpers import generate_token - -from submit.factory import create_ui_web_app -from arxiv.submission.services import classic -from arxiv_auth.auth import scopes -from arxiv_auth.domain import Category -from http import HTTPStatus as status -from arxiv.submission.domain.event import * -from arxiv.submission.domain.agent import User -from arxiv.submission.domain.submission import Author, SubmissionContent -from arxiv.submission import save -from .csrf_util import parse_csrf_token - - -# TODO: finish building out this test suite. The current tests run up to -# file upload. Once the remaining stages have stabilized, this should have -# tests from end to end. -# TODO: add a test where the user tries to jump around in the workflow, and -# verify that stage completion order is enforced. -class TestSubmissionWorkflow(TestCase): - """Tests that progress through the ui-app workflow in various ways.""" - - @mock.patch('arxiv.submission.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create an application instance.""" - self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) - os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET')) - _, self.db = tempfile.mkstemp(suffix='.db') - self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[ - Category('astro-ph.GA'), - Category('astro-ph.CO'), - ]) - self.headers = {'Authorization': self.token} - self.client = self.app.test_client() - with self.app.app_context(): - classic.create_all() - - def tearDown(self): - """Remove the temporary database.""" - os.remove(self.db) - - def _parse_csrf_token(self, response): - try: - return parse_csrf_token(response) - except AttributeError: - self.fail('Could not find CSRF token') - - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_create_submission(self): - """User creates a new ui-app, and proceeds up to upload stage.""" - # Get the ui-app creation page. - response = self.client.get('/', headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - token = self._parse_csrf_token(response) - - # Create a ui-app. - response = self.client.post('/', - data={'new': 'new', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This should be the verify_user - # stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('verify_user', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn( - b'By checking this box, I verify that my user information is' - b' correct.', - response.data - ) - token = self._parse_csrf_token(response) - upload_id, _ = next_page.path.lstrip('/').split('/verify_user', 1) - - # Make sure that the user cannot skip forward to subsequent steps. - response = self.client.get(f'/{upload_id}/file_upload') - self.assertEqual(response.status_code, status.FOUND) - - response = self.client.get(f'/{upload_id}/final_preview') - self.assertEqual(response.status_code, status.FOUND) - - response = self.client.get(f'/{upload_id}/add_optional_metadata') - self.assertEqual(response.status_code, status.FOUND) - - # Submit the verify user page. - response = self.client.post(next_page.path, - data={'verify_user': 'y', - 'action': 'next', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the authorship stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('authorship', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn(b'I am an author of this paper', response.data) - token = self._parse_csrf_token(response) - - # Submit the authorship page. - response = self.client.post(next_page.path, - data={'authorship': 'y', - 'action': 'next', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the license stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('license', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn(b'Select a License', response.data) - token = self._parse_csrf_token(response) - - # Submit the license page. - selected = "http://creativecommons.org/licenses/by-sa/4.0/" - response = self.client.post(next_page.path, - data={'license': selected, - 'action': 'next', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the policy stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('policy', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn( - b'By checking this box, I agree to the policies listed on' - b' this page', - response.data - ) - token = self._parse_csrf_token(response) - - # Submit the policy page. - response = self.client.post(next_page.path, - data={'policy': 'y', - 'action': 'next', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the primary category stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('classification', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn(b'Choose a Primary Classification', response.data) - token = self._parse_csrf_token(response) - - # Submit the primary category page. - response = self.client.post(next_page.path, - data={'category': 'astro-ph.GA', - 'action': 'next', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the cross list stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('cross', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn(b'Choose Cross-List Classifications', response.data) - token = self._parse_csrf_token(response) - - # Submit the cross-list category page. - response = self.client.post(next_page.path, - data={'category': 'astro-ph.CO', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.OK) - - response = self.client.post(next_page.path, - data={'action':'next'}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This is the file upload stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('upload', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn(b'Upload Files', response.data) - token = self._parse_csrf_token(response) - - -class TestEndorsementMessaging(TestCase): - """Verify submitter is shown appropriate messaging about endoresement.""" - - def setUp(self): - """Create an application instance.""" - self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) - os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) - _, self.db = tempfile.mkstemp(suffix='.db') - self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' - self.client = self.app.test_client() - with self.app.app_context(): - classic.create_all() - - def tearDown(self): - """Remove the temporary database.""" - os.remove(self.db) - - def _parse_csrf_token(self, response): - try: - return parse_csrf_token(response) - except AttributeError: - self.fail('Could not find CSRF token') - return token - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_no_endorsements(self): - """User is not endorsed (auto or otherwise) for anything.""" - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[]) - self.headers = {'Authorization': self.token} - - # Get the ui-app creation page. - response = self.client.get('/', headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - token = self._parse_csrf_token(response) - - # Create a ui-app. - response = self.client.post('/', - data={'new': 'new', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This should be the verify_user - # stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('verify_user', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn( - b'Your account does not currently have any endorsed categories.', - response.data, - 'User should be informed that they have no endorsements.' - ) - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_some_categories(self): - """User is endorsed (auto or otherwise) for some categories.""" - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[Category("cs.DL"), - Category("cs.AI")]) - self.headers = {'Authorization': self.token} - - # Get the ui-app creation page. - response = self.client.get('/', headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - token = self._parse_csrf_token(response) - - # Create a ui-app. - response = self.client.post('/', - data={'new': 'new', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This should be the verify_user - # stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('verify_user', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn( - b'You are currently endorsed for', - response.data, - 'User should be informed that they have some endorsements.' - ) - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_some_archives(self): - """User is endorsed (auto or otherwise) for some whole archives.""" - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[Category("cs.*"), - Category("math.*")]) - self.headers = {'Authorization': self.token} - - # Get the ui-app creation page. - response = self.client.get('/', headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - token = self._parse_csrf_token(response) - - # Create a ui-app. - response = self.client.post('/', - data={'new': 'new', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This should be the verify_user - # stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('verify_user', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertIn( - b'You are currently endorsed for', - response.data, - 'User should be informed that they have some endorsements.' - ) - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_all_endorsements(self): - """User is endorsed for everything.""" - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=["*.*"]) - self.headers = {'Authorization': self.token} - - # Get the ui-app creation page. - response = self.client.get('/', headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - token = self._parse_csrf_token(response) - - # Create a ui-app. - response = self.client.post('/', - data={'new': 'new', - 'csrf_token': token}, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - # Get the next page in the process. This should be the verify_user - # stage. - next_page = urlparse(response.headers['Location']) - self.assertIn('verify_user', next_page.path) - response = self.client.get(next_page.path, headers=self.headers) - self.assertNotIn( - b'Your account does not currently have any endorsed categories.', - response.data, - 'User should see no messaging about endorsement.' - ) - self.assertNotIn( - b'You are currently endorsed for', - response.data, - 'User should see no messaging about endorsement.' - ) - - -class TestJREFWorkflow(TestCase): - """Tests that progress through the JREF workflow.""" - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create an application instance.""" - self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) - os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) - _, self.db = tempfile.mkstemp(suffix='.db') - self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' - self.user = User('1234', 'foo@bar.com', endorsements=['astro-ph.GA']) - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[ - Category('astro-ph.GA'), - Category('astro-ph.CO'), - ]) - self.headers = {'Authorization': self.token} - self.client = self.app.test_client() - - # Create and announce a ui-app. - with self.app.app_context(): - classic.create_all() - session = classic.current_session() - - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - self.submission, _ = save( - CreateSubmission(creator=self.user), - ConfirmContactInformation(creator=self.user), - ConfirmAuthorship(creator=self.user, submitter_is_author=True), - SetLicense( - creator=self.user, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.user), - SetPrimaryClassification(creator=self.user, - category='astro-ph.GA'), - SetUploadPackage( - creator=self.user, - checksum="a9s9k342900skks03330029k", - source_format=SubmissionContent.Format.TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=59392, - ), - SetTitle(creator=self.user, title='foo title'), - SetAbstract(creator=self.user, abstract='ab stract' * 20), - SetComments(creator=self.user, comments='indeed'), - SetReportNumber(creator=self.user, report_num='the number 12'), - SetAuthors( - creator=self.user, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.user) - ) - - # announced! - db_submission = session.query(classic.models.Submission) \ - .get(self.submission.submission_id) - db_submission.status = classic.models.Submission.ANNOUNCED - db_document = classic.models.Document(paper_id='1234.5678') - db_submission.doc_paper_id = '1234.5678' - db_submission.document = db_document - session.add(db_submission) - session.add(db_document) - session.commit() - - self.submission_id = self.submission.submission_id - - def tearDown(self): - """Remove the temporary database.""" - os.remove(self.db) - - def _parse_csrf_token(self, response): - try: - return parse_csrf_token(response) - except AttributeError: - self.fail('Could not find CSRF token') - return token - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_create_submission(self): - """User creates a new ui-app, and proceeds up to upload stage.""" - # Get the JREF page. - endpoint = f'/{self.submission_id}/jref' - response = self.client.get(endpoint, headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - self.assertIn(b'Journal reference', response.data) - token = self._parse_csrf_token(response) - - # Set the DOI, journal reference, report number. - request_data = {'doi': '10.1000/182', - 'journal_ref': 'foo journal 1992', - 'report_num': 'abc report 42', - 'csrf_token': token} - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - self.assertIn(b'Confirm and Submit', response.data) - token = self._parse_csrf_token(response) - - request_data['confirmed'] = True - request_data['csrf_token'] = token - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - with self.app.app_context(): - session = classic.current_session() - # What happened. - db_submission = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.doc_paper_id == '1234.5678') - self.assertEqual(db_submission.count(), 2, - "Creates a second row for the JREF") - - -class TestWithdrawalWorkflow(TestCase): - """Tests that progress through the withdrawal request workflow.""" - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create an application instance.""" - self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) - os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) - _, self.db = tempfile.mkstemp(suffix='.db') - self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' - self.user = User('1234', 'foo@bar.com', - endorsements=['astro-ph.GA', 'astro-ph.CO']) - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[ - Category('astro-ph.GA'), - Category('astro-ph.CO'), - ]) - self.headers = {'Authorization': self.token} - self.client = self.app.test_client() - - # Create and announce a ui-app. - with self.app.app_context(): - classic.create_all() - session = classic.current_session() - - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - self.submission, _ = save( - CreateSubmission(creator=self.user), - ConfirmContactInformation(creator=self.user), - ConfirmAuthorship(creator=self.user, submitter_is_author=True), - SetLicense( - creator=self.user, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.user), - SetPrimaryClassification(creator=self.user, - category='astro-ph.GA'), - SetUploadPackage( - creator=self.user, - checksum="a9s9k342900skks03330029k", - source_format=SubmissionContent.Format.TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=59392, - ), - SetTitle(creator=self.user, title='foo title'), - SetAbstract(creator=self.user, abstract='ab stract' * 20), - SetComments(creator=self.user, comments='indeed'), - SetReportNumber(creator=self.user, report_num='the number 12'), - SetAuthors( - creator=self.user, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.user) - ) - - # announced! - db_submission = session.query(classic.models.Submission) \ - .get(self.submission.submission_id) - db_submission.status = classic.models.Submission.ANNOUNCED - db_document = classic.models.Document(paper_id='1234.5678') - db_submission.doc_paper_id = '1234.5678' - db_submission.document = db_document - session.add(db_submission) - session.add(db_document) - session.commit() - - self.submission_id = self.submission.submission_id - - def tearDown(self): - """Remove the temporary database.""" - os.remove(self.db) - - def _parse_csrf_token(self, response): - try: - return parse_csrf_token(response) - except AttributeError: - self.fail('Could not find CSRF token') - return token - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_request_withdrawal(self): - """User requests withdrawal of a announced ui-app.""" - # Get the JREF page. - endpoint = f'/{self.submission_id}/withdraw' - response = self.client.get(endpoint, headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - self.assertIn(b'Request withdrawal', response.data) - token = self._parse_csrf_token(response) - - # Set the withdrawal reason, but make it huge. - request_data = {'withdrawal_reason': 'This is the reason' * 400, - 'csrf_token': token} - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.OK) - token = self._parse_csrf_token(response) - - # Set the withdrawal reason to something reasonable (ha). - request_data = {'withdrawal_reason': 'This is the reason', - 'csrf_token': token} - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - self.assertIn(b'Confirm and Submit', response.data) - token = self._parse_csrf_token(response) - - # Confirm the withdrawal request. - request_data['confirmed'] = True - request_data['csrf_token'] = token - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - with self.app.app_context(): - session = classic.current_session() - # What happened. - db_submissions = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.doc_paper_id == '1234.5678') - self.assertEqual(db_submissions.count(), 2, - "Creates a second row for the withdrawal") - db_submission = db_submissions \ - .order_by(classic.models.Submission.submission_id.desc()) \ - .first() - self.assertEqual(db_submission.type, - classic.models.Submission.WITHDRAWAL) - - -class TestUnsubmitWorkflow(TestCase): - """Tests that progress through the unsubmit workflow.""" - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def setUp(self): - """Create an application instance.""" - self.app = create_ui_web_app({"CLASSIC_SESSION_HASH":"lklk23$jk", "SESSION_DURATION":6000, "CLASSIC_COOKIE_NAME": "tapir_session"}) - os.environ['JWT_SECRET'] = str(self.app.config.get('JWT_SECRET', 'fo')) - _, self.db = tempfile.mkstemp(suffix='.db') - self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}' - self.user = User('1234', 'foo@bar.com', endorsements=['astro-ph.GA']) - self.token = generate_token('1234', 'foo@bar.com', 'foouser', - scope=[scopes.CREATE_SUBMISSION, - scopes.EDIT_SUBMISSION, - scopes.VIEW_SUBMISSION, - scopes.READ_UPLOAD, - scopes.WRITE_UPLOAD, - scopes.DELETE_UPLOAD_FILE], - endorsements=[ - Category('astro-ph.GA'), - Category('astro-ph.CO'), - ]) - self.headers = {'Authorization': self.token} - self.client = self.app.test_client() - - # Create a finalized ui-app. - with self.app.app_context(): - classic.create_all() - session = classic.current_session() - - cc0 = 'http://creativecommons.org/publicdomain/zero/1.0/' - self.submission, _ = save( - CreateSubmission(creator=self.user), - ConfirmContactInformation(creator=self.user), - ConfirmAuthorship(creator=self.user, submitter_is_author=True), - SetLicense( - creator=self.user, - license_uri=cc0, - license_name='CC0 1.0' - ), - ConfirmPolicy(creator=self.user), - SetPrimaryClassification(creator=self.user, - category='astro-ph.GA'), - SetUploadPackage( - creator=self.user, - checksum="a9s9k342900skks03330029k", - source_format=SubmissionContent.Format.TEX, - identifier=123, - uncompressed_size=593992, - compressed_size=59392, - ), - SetTitle(creator=self.user, title='foo title'), - SetAbstract(creator=self.user, abstract='ab stract' * 20), - SetComments(creator=self.user, comments='indeed'), - SetReportNumber(creator=self.user, report_num='the number 12'), - SetAuthors( - creator=self.user, - authors=[Author( - order=0, - forename='Bob', - surname='Paulson', - email='Robert.Paulson@nowhere.edu', - affiliation='Fight Club' - )] - ), - FinalizeSubmission(creator=self.user) - ) - - self.submission_id = self.submission.submission_id - - def tearDown(self): - """Remove the temporary database.""" - os.remove(self.db) - - def _parse_csrf_token(self, response): - try: - return parse_csrf_token(response) - except AttributeError: - self.fail('Could not find CSRF token') - return token - - @mock.patch('arxiv.submission.core.StreamPublisher', mock.MagicMock()) - def test_unsubmit_submission(self): - """User unsubmits a ui-app.""" - # Get the unsubmit confirmation page. - endpoint = f'/{self.submission_id}/unsubmit' - response = self.client.get(endpoint, headers=self.headers) - self.assertEqual(response.status_code, status.OK) - self.assertEqual(response.content_type, 'text/html; charset=utf-8') - self.assertIn(b'Unsubmit This Submission', response.data) - token = self._parse_csrf_token(response) - - # Confirm the ui-app should be unsubmitted - request_data = {'confirmed': True, 'csrf_token': token} - response = self.client.post(endpoint, data=request_data, - headers=self.headers) - self.assertEqual(response.status_code, status.SEE_OTHER) - - with self.app.app_context(): - session = classic.current_session() - # What happened. - db_submission = session.query(classic.models.Submission) \ - .filter(classic.models.Submission.submission_id == - self.submission_id).first() - self.assertEqual(db_submission.status, - classic.models.Submission.NOT_SUBMITTED, "") diff --git a/submit/util.py b/submit/util.py deleted file mode 100644 index 6cbd881..0000000 --- a/submit/util.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Utilities and helpers for the :mod:`submit` application.""" - -from typing import Optional, Tuple, List -from datetime import datetime -from werkzeug.exceptions import NotFound -from retry import retry - -from arxiv.base import logging -from arxiv.base.globals import get_application_global -from arxiv.submission.services.classic.exceptions import Unavailable -import arxiv.submission as events - -logger = logging.getLogger(__name__) -logger.propagate = False - - -@retry(tries=5, delay=0.5, backoff=3, exceptions=Unavailable) -def load_submission(submission_id: Optional[int]) \ - -> Tuple[events.domain.Submission, List[events.domain.Event]]: - """ - Load a ui-app by ID. - - Parameters - ---------- - submission_id : int - - Returns - ------- - :class:`events.domain.Submission` - - Raises - ------ - :class:`werkzeug.exceptions.NotFound` - Raised when there is no ui-app with the specified ID. - - """ - if submission_id is None: - logger.debug('No ui-app ID') - raise NotFound('No such ui-app.') - - g = get_application_global() - if g is None or f'submission_{submission_id}' not in g: - try: - submission, submission_events = events.load(submission_id) - except events.exceptions.NoSuchSubmission as e: - raise NotFound('No such ui-app.') from e - if g is not None: - setattr(g, f'submission_{submission_id}', - (submission, submission_events)) - if g is not None: - return getattr(g, f'submission_{submission_id}') - return submission, submission_events - - -def tidy_filesize(size: int) -> str: - """ - Convert upload size to human readable form. - - Decision to use powers of 10 rather than powers of 2 to stay compatible - with Jinja filesizeformat filter with binary=false setting that we are - using in file_upload template. - - Parameter: size in bytes - Returns: formatted string of size in units up through GB - - """ - units = ["B", "KB", "MB", "GB"] - if size == 0: - return "0B" - if size > 1000000000: - return '{} {}'.format(size, units[3]) - units_index = 0 - while size > 1000: - units_index += 1 - size = round(size / 1000, 3) - return '{} {}'.format(size, units[units_index]) - - -# TODO: remove me! -def announce_submission(submission_id: int) -> None: - """WARNING WARNING WARNING this is for testing purposes only.""" - dbss = events.services.classic._get_db_submission_rows(submission_id) - head = sorted([o for o in dbss if o.is_new_version()], key=lambda o: o.submission_id)[-1] - session = events.services.classic.current_session() - if not head.is_announced(): - head.status = events.services.classic.models.Submission.ANNOUNCED - if head.document is None: - paper_id = datetime.now().strftime('%s')[-4:] \ - + "." \ - + datetime.now().strftime('%s')[-5:] - head.document = \ - events.services.classic.models.Document(paper_id=paper_id) - head.doc_paper_id = paper_id - session.add(head) - session.commit() - - -# TODO: remove me! -def place_on_hold(submission_id: int) -> None: - """WARNING WARNING WARNING this is for testing purposes only.""" - dbss = events.services.classic._get_db_submission_rows(submission_id) - i = events.services.classic._get_head_idx(dbss) - head = dbss[i] - session = events.services.classic.current_session() - if head.is_announced() or head.is_on_hold(): - return - head.status = events.services.classic.models.Submission.ON_HOLD - session.add(head) - session.commit() - - -# TODO: remove me! -def apply_cross(submission_id: int) -> None: - session = events.services.classic.current_session() - dbss = events.services.classic._get_db_submission_rows(submission_id) - i = events.services.classic._get_head_idx(dbss) - for dbs in dbss[:i]: - if dbs.is_crosslist(): - dbs.status = events.services.classic.models.Submission.ANNOUNCED - session.add(dbs) - session.commit() - - -# TODO: remove me! -def reject_cross(submission_id: int) -> None: - session = events.services.classic.current_session() - dbss = events.services.classic._get_db_submission_rows(submission_id) - i = events.services.classic._get_head_idx(dbss) - for dbs in dbss[:i]: - if dbs.is_crosslist(): - dbs.status = events.services.classic.models.Submission.REMOVED - session.add(dbs) - session.commit() - - -# TODO: remove me! -def apply_withdrawal(submission_id: int) -> None: - session = events.services.classic.current_session() - dbss = events.services.classic._get_db_submission_rows(submission_id) - i = events.services.classic._get_head_idx(dbss) - for dbs in dbss[:i]: - if dbs.is_withdrawal(): - dbs.status = events.services.classic.models.Submission.ANNOUNCED - session.add(dbs) - session.commit() - - -# TODO: remove me! -def reject_withdrawal(submission_id: int) -> None: - session = events.services.classic.current_session() - dbss = events.services.classic._get_db_submission_rows(submission_id) - i = events.services.classic._get_head_idx(dbss) - for dbs in dbss[:i]: - if dbs.is_withdrawal(): - dbs.status = events.services.classic.models.Submission.REMOVED - session.add(dbs) - session.commit() diff --git a/submit/workflow/__init__.py b/submit/workflow/__init__.py deleted file mode 100644 index 2fe1788..0000000 --- a/submit/workflow/__init__.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Defines ui-app stages and workflows supported by this UI.""" - -from typing import Iterable, Optional, Callable, List, Iterator, Union - -from arxiv.submission.domain import Submission -from dataclasses import dataclass, field - -from . import stages -from .stages import Stage - - -@dataclass -class WorkflowDefinition: - name: str - order: List[Stage] = field(default_factory=list) - confirmation: Stage = None - - def __iter__(self) -> Iterator[Stage]: - """Iterate over stages in this workflow.""" - for stage in self.order: - yield stage - - def iter_prior(self, stage: Stage) -> Iterable[Stage]: - """Iterate over stages in this workflow up to a particular stage.""" - for prior_stage in self.order: - if prior_stage == stage: - return - yield prior_stage - - def next_stage(self, stage: Optional[Stage]) -> Optional[Stage]: - """Get the next stage.""" - if stage is None: - return None - idx = self.order.index(stage) - if idx + 1 >= len(self.order): - return None - return self.order[idx + 1] - - def previous_stage(self, stage: Optional[Stage]) -> Optional[Stage]: - """Get the previous stage.""" - if stage is None: - return None - idx = self.order.index(stage) - if idx == 0: - return None - return self.order[idx - 1] - - def stage_from_endpoint(self, endpoint: str) -> Stage: - """Get the :class:`.Stage` for an endpoint.""" - for stage in self.order: - if stage.endpoint == endpoint: - return stage - raise ValueError(f'No stage for endpoint: {endpoint}') - return self.order[0] # mypy - - def index(self, stage: Union[type, Stage, str]) -> int: - if stage in self.order: - return self.order.index(stage) - if isinstance(stage, type) and issubclass(stage, Stage): - for idx, st in enumerate(self.order): - if issubclass(st.__class__, stage): - return idx - raise ValueError(f"{stage} not In workflow") - - if isinstance(stage, str): # it could be classname, stage label - for idx, wstg in self.order: - if(wstg.label == stage - or wstg.__class__.__name__ == stage): - return idx - - raise ValueError(f"Should be subclass of Stage, classname or stage" - f"instance. Cannot call with {stage} of type " - f"{type(stage)}") - - def __getitem__(self, query: Union[type, Stage, str, int, slice])\ - -> Union[Optional[Stage], List[Stage]]: - if isinstance(query, slice): - return self.order.__getitem__(query) - else: - return self.get_stage(query) - - def get_stage(self, query: Union[type, Stage, str, int])\ - -> Optional[Stage]: - """Get the stage object from this workflow for Class, class name, - stage label, endpoint or index in order """ - if query is None: - return None - if isinstance(query, type): - if issubclass(query, Stage): - stages = [st for st in self.order if issubclass( - st.__class__, query)] - if len(stages) > 0: - return stages[0] - else: - return None - else: - raise ValueError("Cannot call get_stage with non-Stage class") - if isinstance(query, int): - if query >= len(self.order) or query < 0: - return None - else: - return self.order[query] - - if isinstance(query, str): - # it could be classname, stage label or stage endpoint - for stage in self.order: - if(stage.label == query - or stage.__class__.__name__ == query - or stage.endpoint == query): - return stage - return None - if query in self.order: - return self[self.order.index(query)] - raise ValueError("query should be Stage class or class name or " - f"endpoint or lable str or int. Not {type(query)}") - - -SubmissionWorkflow = WorkflowDefinition( - 'SubmissionWorkflow', - [stages.VerifyUser(), - stages.Authorship(), - stages.License(), - stages.Policy(), - stages.Classification(), - stages.CrossList(required=False, must_see=True), - stages.FileUpload(), - stages.Process(), - stages.Metadata(), - stages.OptionalMetadata(required=False, must_see=True), - stages.FinalPreview() - ], - stages.Confirm() -) -"""Workflow for new submissions.""" - -ReplacementWorkflow = WorkflowDefinition( - 'ReplacementWorkflow', - [stages.VerifyUser(must_see=True), - stages.Authorship(must_see=True), - stages.License(must_see=True), - stages.Policy(must_see=True), - stages.FileUpload(must_see=True), - stages.Process(must_see=True), - stages.Metadata(must_see=True), - stages.OptionalMetadata(required=False, must_see=True), - stages.FinalPreview(must_see=True) - ], - stages.Confirm() -) -"""Workflow for replacements.""" diff --git a/submit/workflow/conditions.py b/submit/workflow/conditions.py deleted file mode 100644 index 159a85c..0000000 --- a/submit/workflow/conditions.py +++ /dev/null @@ -1,72 +0,0 @@ -from arxiv.submission.domain import Submission, SubmissionContent - - -def is_contact_verified(submission: Submission) -> bool: - """Determine whether the submitter has verified their information.""" - return submission.submitter_contact_verified is True - - -def is_authorship_indicated(submission: Submission) -> bool: - """Determine whether the submitter has indicated authorship.""" - return submission.submitter_is_author is not None - - -def has_license(submission: Submission) -> bool: - """Determine whether the submitter has selected a license.""" - return submission.license is not None - - -def is_policy_accepted(submission: Submission) -> bool: - """Determine whether the submitter has accepted arXiv policies.""" - return submission.submitter_accepts_policy is True - - -def has_primary(submission: Submission) -> bool: - """Determine whether the submitter selected a primary category.""" - return submission.primary_classification is not None - - -def has_secondary(submission: Submission) -> bool: - return len(submission.secondary_classification) > 0 - - -def has_valid_content(submission: Submission) -> bool: - """Determine whether the submitter has uploaded files.""" - return submission.source_content is not None and\ - submission.source_content.checksum is not None and\ - submission.source_content.source_format is not None and \ - submission.source_content.uncompressed_size > 0 and \ - submission.source_content.source_format != SubmissionContent.Format.INVALID - -def has_non_processing_content(submission: Submission) -> bool: - return (submission.source_content is not None and - submission.source_content.source_format is not None and - (submission.source_content.source_format != SubmissionContent.Format.TEX - and - submission.source_content.source_format != SubmissionContent.Format.POSTSCRIPT)) - -def is_source_processed(submission: Submission) -> bool: - """Determine whether the submitter has compiled their upload.""" - return has_valid_content(submission) and \ - (submission.is_source_processed or has_non_processing_content(submission)) - - -def is_metadata_complete(submission: Submission) -> bool: - """Determine whether the submitter has entered required metadata.""" - return (submission.metadata.title is not None - and submission.metadata.abstract is not None - and submission.metadata.authors_display is not None) - - -def is_opt_metadata_complete(submission: Submission) -> bool: - """Determine whether the user has set optional metadata fields.""" - return (submission.metadata.doi is not None - or submission.metadata.msc_class is not None - or submission.metadata.acm_class is not None - or submission.metadata.report_num is not None - or submission.metadata.journal_ref is not None) - - -def is_finalized(submission: Submission) -> bool: - """Determine whether the ui-app is finalized.""" - return bool(submission.is_finalized) diff --git a/submit/workflow/processor.py b/submit/workflow/processor.py deleted file mode 100644 index 9c621e9..0000000 --- a/submit/workflow/processor.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Defines ui-app stages and workflows supported by this UI.""" - -from typing import Optional, Dict - -from arxiv.base import logging -from arxiv.submission.domain import Submission -from dataclasses import field, dataclass -from . import WorkflowDefinition, Stage - - -logger = logging.getLogger(__file__) - - -@dataclass -class WorkflowProcessor: - """Class to handle a ui-app moving through a WorkflowDefinition. - - The seen methods is_seen and mark_seen are handled with a Dict. This class - doesn't handle loading or saving that data. - """ - workflow: WorkflowDefinition - submission: Submission - seen: Dict[str, bool] = field(default_factory=dict) - - def is_complete(self) -> bool: - """Determine whether this workflow is complete.""" - return bool(self.submission.is_finalized) - - def next_stage(self, stage: Optional[Stage]) -> Optional[Stage]: - """Get the stage after the one in the parameter.""" - return self.workflow.next_stage(stage) - - def can_proceed_to(self, stage: Optional[Stage]) -> bool: - """Determine whether the user can proceed to a stage.""" - if stage is None: - return True - - must_be_done = self.workflow.order if stage == self.workflow.confirmation \ - else self.workflow.iter_prior(stage) - done = list([(stage, self.is_done(stage)) for stage in must_be_done]) - logger.debug(f'in can_proceed_to() done list is {done}') - return all(map(lambda x: x[1], done)) - - def current_stage(self) -> Optional[Stage]: - """Get the first stage in the workflow that is not done.""" - for stage in self.workflow.order: - if not self.is_done(stage): - return stage - return None - - def _seen_key(self, stage: Stage) -> str: - return f"{self.workflow.name}---" +\ - f"{stage.__class__.__name__}---{stage.label}---" - - def mark_seen(self, stage: Optional[Stage]) -> None: - """Mark a stage as seen by the user.""" - if stage is not None: - self.seen[self._seen_key(stage)] = True - - def is_seen(self, stage: Optional[Stage]) -> bool: - """Determine whether or not the user has seen this stage.""" - if stage is None: - return True - return self.seen.get(self._seen_key(stage), False) - - def is_done(self, stage: Optional[Stage]) -> bool: - """ - Evaluate whether a stage is sufficiently addressed for this workflow. - This considers whether the stage is complete (if required), and whether - the stage has been seen (if it must be seen). - """ - if stage is None: - return True - - return ((not stage.must_see or self.is_seen(stage)) - and - (not stage.required or stage.is_complete(self.submission))) - - def index(self, stage): - return self.workflow.index(stage) - diff --git a/submit/workflow/stages.py b/submit/workflow/stages.py deleted file mode 100644 index 1a274cf..0000000 --- a/submit/workflow/stages.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Workflow and stages related to new submissions""" - -from typing import Iterable, Optional, Callable, List, Iterator -from . import conditions -from arxiv.submission.domain import Submission - - -SubmissionCheck = Callable[[Submission], (bool)] -"""Function type that can be used to check if a ui-app meets - a condition.""" - - -class Stage: - """Class for workflow stages.""" - - endpoint: str - label: str - title: str - display: str - must_see: bool - required: bool - completed: List[SubmissionCheck] - - def __init__(self, required: bool = True, must_see: bool = False) -> None: - """ - Configure the stage for a particular workflow. - Parameters - ---------- - required : bool - This stage must be complete to proceed. - must_see : bool - This stage must be seen (even if already complete) to proceed. - """ - self.required = required - self.must_see = must_see - - def is_complete(self, submission: Submission) -> bool: - return all([fn(submission) for fn in self.completed]) - - -class VerifyUser(Stage): - """The user is asked to verify their personal information.""" - - endpoint = 'verify_user' - label = 'verify your personal information' - title = 'Verify user info' - display = 'Verify User' - completed = [conditions.is_contact_verified] - - -class Authorship(Stage): - """The user is asked to verify their authorship status.""" - - endpoint = 'authorship' - label = 'confirm authorship' - title = "Confirm authorship" - display = "Authorship" - completed = [conditions.is_authorship_indicated] - - -class License(Stage): - """The user is asked to select a license.""" - - endpoint = 'license' - label = 'choose a license' - title = "Choose license" - display = "License" - completed = [conditions.has_license] - - -class Policy(Stage): - """The user is required to agree to arXiv policies.""" - - endpoint = 'policy' - label = 'accept arXiv ui-app policies' - title = "Acknowledge policy" - display = "Policy" - completed = [conditions.is_policy_accepted] - - -class Classification(Stage): - """The user is asked to select a primary category.""" - - endpoint = 'classification' - label = 'select a primary category' - title = "Choose category" - display = "Category" - completed = [conditions.has_primary] - - -class CrossList(Stage): - """The user is given the option of selecting cross-list categories.""" - - endpoint = 'cross_list' - label = 'add cross-list categories' - title = "Add cross-list" - display = "Cross-list" - completed = [conditions.has_secondary] - - -class FileUpload(Stage): - """The user is asked to upload files for their ui-app.""" - - endpoint = 'file_upload' - label = 'upload your ui-app files' - title = "File upload" - display = "Upload Files" - always_check = True - completed = [conditions.has_valid_content] - - -class Process(Stage): - """Uploaded files are processed; this is primarily to compile LaTeX.""" - - endpoint = 'file_process' - label = 'process your ui-app files' - title = "File process" - display = "Process Files" - """We need to re-process every time the source is updated.""" - completed = [conditions.is_source_processed] - - -class Metadata(Stage): - """The user is asked to require core metadata fields, like title.""" - - endpoint = 'add_metadata' - label = 'add required metadata' - title = "Add metadata" - display = "Metadata" - completed = [conditions.is_metadata_complete] - - -class OptionalMetadata(Stage): - """The user is given the option of entering optional metadata.""" - - endpoint = 'add_optional_metadata' - label = 'add optional metadata' - title = "Add optional metadata" - display = "Opt. Metadata" - completed = [conditions.is_opt_metadata_complete] - - -class FinalPreview(Stage): - """The user is asked to review the ui-app before finalizing.""" - - endpoint = 'final_preview' - label = 'preview and approve your ui-app' - title = "Final preview" - display = "Preview" - completed = [conditions.is_finalized] - - -class Confirm(Stage): - """The ui-app is confirmed.""" - - endpoint = 'confirmation' - label = 'your ui-app is confirmed' - title = "Submission confirmed" - display = "Confirmed" - completed = [lambda _:False] - diff --git a/submit/workflow/test_new_submission.py b/submit/workflow/test_new_submission.py deleted file mode 100644 index 7fb893c..0000000 --- a/submit/workflow/test_new_submission.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for workflow""" - -from unittest import TestCase, mock -from submit import workflow -from submit.workflow import processor -from arxiv.submission.domain.event import CreateSubmission -from arxiv.submission.domain.agent import User -from submit.workflow.stages import * -from arxiv.submission.domain.submission import SubmissionContent, SubmissionMetadata - - -class TestNewSubmissionWorkflow(TestCase): - - def testWorkflowGetitem(self): - wf = workflow.WorkflowDefinition( - name='TestingWorkflow', - order=[VerifyUser(), Policy(), FinalPreview()]) - - self.assertIsNotNone(wf[VerifyUser]) - self.assertEqual(wf[VerifyUser].__class__, VerifyUser) - self.assertEqual(wf[0].__class__, VerifyUser) - - self.assertEqual(wf[VerifyUser], wf[0]) - self.assertEqual(wf[VerifyUser], wf['VerifyUser']) - self.assertEqual(wf[VerifyUser], wf['verify_user']) - self.assertEqual(wf[VerifyUser], wf[wf.order[0]]) - - self.assertEqual(next(wf.iter_prior(wf[Policy])), wf[VerifyUser]) - - def testVerifyUser(self): - seen = {} - submitter = User('Bob', 'FakePants', 'Sponge', - 'bob_id_xy21', 'cornell.edu', 'UNIT_TEST_AGENT') - cevnt = CreateSubmission(creator=submitter, client=submitter) - submission = cevnt.apply(None) - - nswfps = processor.WorkflowProcessor(workflow.SubmissionWorkflow, - submission, seen) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[VerifyUser]) - - submission.submitter_contact_verified = True - nswfps.mark_seen(nswfps.workflow[VerifyUser]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[Authorship]) - - submission.submitter_is_author = True - nswfps.mark_seen(nswfps.workflow[Authorship]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[License]) - - submission.license = "someLicense" - nswfps.mark_seen(nswfps.workflow[License]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Classification])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[Policy]) - - submission.submitter_accepts_policy = True - nswfps.mark_seen(nswfps.workflow[Policy]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), - nswfps.workflow[Classification]) - - submission.primary_classification = {'category': "FakePrimaryCategory"} - nswfps.mark_seen(nswfps.workflow[Classification]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[CrossList]) - - submission.secondary_classification = [ - {'category': 'fakeSecondaryCategory'}] - nswfps.mark_seen(nswfps.workflow[CrossList]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[FileUpload]) - - submission.source_content = SubmissionContent( - 'identifierX', 'checksum_xyz', 100, 10, SubmissionContent.Format.TEX) - nswfps.mark_seen(nswfps.workflow[FileUpload]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertFalse(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[Process]) - - #Now try a PDF upload - submission.source_content = SubmissionContent( - 'identifierX', 'checksum_xyz', 100, 10, SubmissionContent.Format.PDF) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - - self.assertFalse(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - self.assertEqual(nswfps.current_stage(), nswfps.workflow[Metadata]) - - submission.metadata = SubmissionMetadata(title="FakeOFakeyDuFakeFake", - abstract="I like it.", - authors_display="Bob Fakeyfake") - nswfps.mark_seen(nswfps.workflow[Metadata]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - self.assertEqual(nswfps.current_stage(), nswfps.workflow[OptionalMetadata]) - - #optional metadata only seen - nswfps.mark_seen(nswfps.workflow[OptionalMetadata]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - - self.assertFalse(nswfps.can_proceed_to(nswfps.workflow.confirmation)) - - submission.status = 'submitted' - nswfps.mark_seen(nswfps.workflow[FinalPreview]) - - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[VerifyUser])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Authorship])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[License])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Policy])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[Classification])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[CrossList])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FileUpload])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Process])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[Metadata])) - self.assertTrue(nswfps.can_proceed_to( - nswfps.workflow[OptionalMetadata])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow[FinalPreview])) - self.assertTrue(nswfps.can_proceed_to(nswfps.workflow.confirmation)) From 32a93ccde3a499ae50ce97dd075b98a7ace66b04 Mon Sep 17 00:00:00 2001 From: "Brian D. Caruso" Date: Tue, 24 Sep 2024 07:20:31 -0400 Subject: [PATCH 28/28] typo in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b0d0a1..2d111dd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pip install --no-deps -r requirements.txt pip install --no-deps -r requirements-dev.txt # make sqlite dev db -python test/make_test_db.py +python tests/make_test_db.py python main.py ```