diff --git a/README.txt b/README.txt index 5dc849e1..b4d5e430 100644 --- a/README.txt +++ b/README.txt @@ -74,13 +74,6 @@ configurable. Features ======== -Telnet shell ------------- - -Telnet into localhost port 6023 to get an interactive console. The console can -currently be used to inspect the scheduler's live status. - - Self-check ---------- diff --git a/changelog.d/20230626_005413_jb_replace_telnet_with_http.rst b/changelog.d/20230626_005413_jb_replace_telnet_with_http.rst new file mode 100644 index 00000000..0e2ab2a9 --- /dev/null +++ b/changelog.d/20230626_005413_jb_replace_telnet_with_http.rst @@ -0,0 +1,5 @@ +- Replace prettytable with rich + +- Replace telnet shell with HTTP API + +- Migrate `backy check` to `backy client check` and use the new HTTP API diff --git a/doc/backy.conf.example b/doc/backy.conf.example index db7c1821..fda75436 100644 --- a/doc/backy.conf.example +++ b/doc/backy.conf.example @@ -1,6 +1,18 @@ global: base-dir: /my/backydir worker-limit: 3 +api: + addrs: "127.0.0.1, ::1" + port: 1234 + tokens: + "test-token": "test-server" + "cli-token": "cli" + cli-default: + token: "cli-token" +peers: + "test-server": + url: "https://example.com:1234" + token: "token2" schedules: default: daily: @@ -37,8 +49,8 @@ jobs: test03: schedule: hourly source: - type: flyingcircus, + type: flyingcircus consul_acl_token: a211c244-846b-11e5-999b-081196cf15b4 - vm: test03, + vm: test03 pool: test image: test03.root diff --git a/doc/man-backy.rst b/doc/man-backy.rst index ec71634f..203d62c6 100644 --- a/doc/man-backy.rst +++ b/doc/man-backy.rst @@ -241,24 +241,6 @@ environment variables like **CEPH_CLUSTER** or **CEPH_ARGS**. **backy scheduler** processes exit cleanly on SIGTERM. -Telnet shell ------------- - -The schedules opens a telnet server (default: localhost port 6023) for live -inspection. The telnet interface accepts the following commands: - -jobs [REGEX] - Prints an overview of all configured jobs together with their last and - next backup run. An optional (extended) regular expression restricts output - to matching job names. - -status - Dumps internal server status details. - -quit - Exits the telnet shell. - - Files ----- @@ -288,12 +270,12 @@ config status-interval Update status file every N seconds (default: 30). - telnet-addrs - Comma-separated list of listen addresses for the telnet server + api-addrs + Comma-separated list of listen addresses for the api server (default: 127.0.0.1, ::1). - telnet-port - Port number of the telnet server (default: 6023). + api-port + Port number of the api server (default: 6023). .. _schedules: diff --git a/flake.nix b/flake.nix index c44274dd..af55d21a 100644 --- a/flake.nix +++ b/flake.nix @@ -20,9 +20,6 @@ scriv = super.scriv.overrideAttrs (old: { buildInputs = (old.buildInputs or []) ++ [ super.setuptools ]; }); - telnetlib3 = super.telnetlib3.overrideAttrs (old: { - buildInputs = (old.buildInputs or []) ++ [ super.setuptools ]; - }); execnet = super.execnet.overrideAttrs (old: { buildInputs = (old.buildInputs or []) ++ [ super.hatchling super.hatch-vcs ]; }); diff --git a/poetry.lock b/poetry.lock index 2cf7dd43..d125dd4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,146 @@ # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +[[package]] +name = "aiohttp" +version = "3.8.5" +description = "Async http client/server framework (asyncio)" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a94159871304770da4dd371f4291b20cac04e8c94f11bdea1c3478e557fbe0d8"}, + {file = "aiohttp-3.8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13bf85afc99ce6f9ee3567b04501f18f9f8dbbb2ea11ed1a2e079670403a7c84"}, + {file = "aiohttp-3.8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ce2ac5708501afc4847221a521f7e4b245abf5178cf5ddae9d5b3856ddb2f3a"}, + {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96943e5dcc37a6529d18766597c491798b7eb7a61d48878611298afc1fca946c"}, + {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ad5c3c4590bb3cc28b4382f031f3783f25ec223557124c68754a2231d989e2b"}, + {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c413c633d0512df4dc7fd2373ec06cc6a815b7b6d6c2f208ada7e9e93a5061d"}, + {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df72ac063b97837a80d80dec8d54c241af059cc9bb42c4de68bd5b61ceb37caa"}, + {file = "aiohttp-3.8.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c48c5c0271149cfe467c0ff8eb941279fd6e3f65c9a388c984e0e6cf57538e14"}, + {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:368a42363c4d70ab52c2c6420a57f190ed3dfaca6a1b19afda8165ee16416a82"}, + {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7607ec3ce4993464368505888af5beb446845a014bc676d349efec0e05085905"}, + {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0d21c684808288a98914e5aaf2a7c6a3179d4df11d249799c32d1808e79503b5"}, + {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:312fcfbacc7880a8da0ae8b6abc6cc7d752e9caa0051a53d217a650b25e9a691"}, + {file = "aiohttp-3.8.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ad093e823df03bb3fd37e7dec9d4670c34f9e24aeace76808fc20a507cace825"}, + {file = "aiohttp-3.8.5-cp310-cp310-win32.whl", hash = "sha256:33279701c04351a2914e1100b62b2a7fdb9a25995c4a104259f9a5ead7ed4802"}, + {file = "aiohttp-3.8.5-cp310-cp310-win_amd64.whl", hash = "sha256:6e4a280e4b975a2e7745573e3fc9c9ba0d1194a3738ce1cbaa80626cc9b4f4df"}, + {file = "aiohttp-3.8.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae871a964e1987a943d83d6709d20ec6103ca1eaf52f7e0d36ee1b5bebb8b9b9"}, + {file = "aiohttp-3.8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:461908b2578955045efde733719d62f2b649c404189a09a632d245b445c9c975"}, + {file = "aiohttp-3.8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72a860c215e26192379f57cae5ab12b168b75db8271f111019509a1196dfc780"}, + {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc14be025665dba6202b6a71cfcdb53210cc498e50068bc088076624471f8bb9"}, + {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8af740fc2711ad85f1a5c034a435782fbd5b5f8314c9a3ef071424a8158d7f6b"}, + {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:841cd8233cbd2111a0ef0a522ce016357c5e3aff8a8ce92bcfa14cef890d698f"}, + {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed1c46fb119f1b59304b5ec89f834f07124cd23ae5b74288e364477641060ff"}, + {file = "aiohttp-3.8.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84f8ae3e09a34f35c18fa57f015cc394bd1389bce02503fb30c394d04ee6b938"}, + {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62360cb771707cb70a6fd114b9871d20d7dd2163a0feafe43fd115cfe4fe845e"}, + {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:23fb25a9f0a1ca1f24c0a371523546366bb642397c94ab45ad3aedf2941cec6a"}, + {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0ba0d15164eae3d878260d4c4df859bbdc6466e9e6689c344a13334f988bb53"}, + {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5d20003b635fc6ae3f96d7260281dfaf1894fc3aa24d1888a9b2628e97c241e5"}, + {file = "aiohttp-3.8.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0175d745d9e85c40dcc51c8f88c74bfbaef9e7afeeeb9d03c37977270303064c"}, + {file = "aiohttp-3.8.5-cp311-cp311-win32.whl", hash = "sha256:2e1b1e51b0774408f091d268648e3d57f7260c1682e7d3a63cb00d22d71bb945"}, + {file = "aiohttp-3.8.5-cp311-cp311-win_amd64.whl", hash = "sha256:043d2299f6dfdc92f0ac5e995dfc56668e1587cea7f9aa9d8a78a1b6554e5755"}, + {file = "aiohttp-3.8.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cae533195e8122584ec87531d6df000ad07737eaa3c81209e85c928854d2195c"}, + {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f21e83f355643c345177a5d1d8079f9f28b5133bcd154193b799d380331d5d3"}, + {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7a75ef35f2df54ad55dbf4b73fe1da96f370e51b10c91f08b19603c64004acc"}, + {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e2e9839e14dd5308ee773c97115f1e0a1cb1d75cbeeee9f33824fa5144c7634"}, + {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44e65da1de4403d0576473e2344828ef9c4c6244d65cf4b75549bb46d40b8dd"}, + {file = "aiohttp-3.8.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d847e4cde6ecc19125ccbc9bfac4a7ab37c234dd88fbb3c5c524e8e14da543"}, + {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:c7a815258e5895d8900aec4454f38dca9aed71085f227537208057853f9d13f2"}, + {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:8b929b9bd7cd7c3939f8bcfffa92fae7480bd1aa425279d51a89327d600c704d"}, + {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:5db3a5b833764280ed7618393832e0853e40f3d3e9aa128ac0ba0f8278d08649"}, + {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a0215ce6041d501f3155dc219712bc41252d0ab76474615b9700d63d4d9292af"}, + {file = "aiohttp-3.8.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:fd1ed388ea7fbed22c4968dd64bab0198de60750a25fe8c0c9d4bef5abe13824"}, + {file = "aiohttp-3.8.5-cp36-cp36m-win32.whl", hash = "sha256:6e6783bcc45f397fdebc118d772103d751b54cddf5b60fbcc958382d7dd64f3e"}, + {file = "aiohttp-3.8.5-cp36-cp36m-win_amd64.whl", hash = "sha256:b5411d82cddd212644cf9360879eb5080f0d5f7d809d03262c50dad02f01421a"}, + {file = "aiohttp-3.8.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:01d4c0c874aa4ddfb8098e85d10b5e875a70adc63db91f1ae65a4b04d3344cda"}, + {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5980a746d547a6ba173fd5ee85ce9077e72d118758db05d229044b469d9029a"}, + {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a482e6da906d5e6e653be079b29bc173a48e381600161c9932d89dfae5942ef"}, + {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80bd372b8d0715c66c974cf57fe363621a02f359f1ec81cba97366948c7fc873"}, + {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1161b345c0a444ebcf46bf0a740ba5dcf50612fd3d0528883fdc0eff578006a"}, + {file = "aiohttp-3.8.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd56db019015b6acfaaf92e1ac40eb8434847d9bf88b4be4efe5bfd260aee692"}, + {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:153c2549f6c004d2754cc60603d4668899c9895b8a89397444a9c4efa282aaf4"}, + {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4a01951fabc4ce26ab791da5f3f24dca6d9a6f24121746eb19756416ff2d881b"}, + {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bfb9162dcf01f615462b995a516ba03e769de0789de1cadc0f916265c257e5d8"}, + {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:7dde0009408969a43b04c16cbbe252c4f5ef4574ac226bc8815cd7342d2028b6"}, + {file = "aiohttp-3.8.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4149d34c32f9638f38f544b3977a4c24052042affa895352d3636fa8bffd030a"}, + {file = "aiohttp-3.8.5-cp37-cp37m-win32.whl", hash = "sha256:68c5a82c8779bdfc6367c967a4a1b2aa52cd3595388bf5961a62158ee8a59e22"}, + {file = "aiohttp-3.8.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2cf57fb50be5f52bda004b8893e63b48530ed9f0d6c96c84620dc92fe3cd9b9d"}, + {file = "aiohttp-3.8.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:eca4bf3734c541dc4f374ad6010a68ff6c6748f00451707f39857f429ca36ced"}, + {file = "aiohttp-3.8.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1274477e4c71ce8cfe6c1ec2f806d57c015ebf84d83373676036e256bc55d690"}, + {file = "aiohttp-3.8.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:28c543e54710d6158fc6f439296c7865b29e0b616629767e685a7185fab4a6b9"}, + {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:910bec0c49637d213f5d9877105d26e0c4a4de2f8b1b29405ff37e9fc0ad52b8"}, + {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5443910d662db951b2e58eb70b0fbe6b6e2ae613477129a5805d0b66c54b6cb7"}, + {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e460be6978fc24e3df83193dc0cc4de46c9909ed92dd47d349a452ef49325b7"}, + {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb1558def481d84f03b45888473fc5a1f35747b5f334ef4e7a571bc0dfcb11f8"}, + {file = "aiohttp-3.8.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34dd0c107799dcbbf7d48b53be761a013c0adf5571bf50c4ecad5643fe9cfcd0"}, + {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aa1990247f02a54185dc0dff92a6904521172a22664c863a03ff64c42f9b5410"}, + {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0e584a10f204a617d71d359fe383406305a4b595b333721fa50b867b4a0a1548"}, + {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:a3cf433f127efa43fee6b90ea4c6edf6c4a17109d1d037d1a52abec84d8f2e42"}, + {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c11f5b099adafb18e65c2c997d57108b5bbeaa9eeee64a84302c0978b1ec948b"}, + {file = "aiohttp-3.8.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:84de26ddf621d7ac4c975dbea4c945860e08cccde492269db4e1538a6a6f3c35"}, + {file = "aiohttp-3.8.5-cp38-cp38-win32.whl", hash = "sha256:ab88bafedc57dd0aab55fa728ea10c1911f7e4d8b43e1d838a1739f33712921c"}, + {file = "aiohttp-3.8.5-cp38-cp38-win_amd64.whl", hash = "sha256:5798a9aad1879f626589f3df0f8b79b3608a92e9beab10e5fda02c8a2c60db2e"}, + {file = "aiohttp-3.8.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a6ce61195c6a19c785df04e71a4537e29eaa2c50fe745b732aa937c0c77169f3"}, + {file = "aiohttp-3.8.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:773dd01706d4db536335fcfae6ea2440a70ceb03dd3e7378f3e815b03c97ab51"}, + {file = "aiohttp-3.8.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f83a552443a526ea38d064588613aca983d0ee0038801bc93c0c916428310c28"}, + {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f7372f7341fcc16f57b2caded43e81ddd18df53320b6f9f042acad41f8e049a"}, + {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea353162f249c8097ea63c2169dd1aa55de1e8fecbe63412a9bc50816e87b761"}, + {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d47ae48db0b2dcf70bc8a3bc72b3de86e2a590fc299fdbbb15af320d2659de"}, + {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d827176898a2b0b09694fbd1088c7a31836d1a505c243811c87ae53a3f6273c1"}, + {file = "aiohttp-3.8.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3562b06567c06439d8b447037bb655ef69786c590b1de86c7ab81efe1c9c15d8"}, + {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4e874cbf8caf8959d2adf572a78bba17cb0e9d7e51bb83d86a3697b686a0ab4d"}, + {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6809a00deaf3810e38c628e9a33271892f815b853605a936e2e9e5129762356c"}, + {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:33776e945d89b29251b33a7e7d006ce86447b2cfd66db5e5ded4e5cd0340585c"}, + {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eaeed7abfb5d64c539e2db173f63631455f1196c37d9d8d873fc316470dfbacd"}, + {file = "aiohttp-3.8.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e91d635961bec2d8f19dfeb41a539eb94bd073f075ca6dae6c8dc0ee89ad6f91"}, + {file = "aiohttp-3.8.5-cp39-cp39-win32.whl", hash = "sha256:00ad4b6f185ec67f3e6562e8a1d2b69660be43070bd0ef6fcec5211154c7df67"}, + {file = "aiohttp-3.8.5-cp39-cp39-win_amd64.whl", hash = "sha256:c0a9034379a37ae42dea7ac1e048352d96286626251862e448933c0f59cbd79c"}, + {file = "aiohttp-3.8.5.tar.gz", hash = "sha256:b9552ec52cc147dbf1944ac7ac98af7602e51ea2dcd076ed194ca3c0d1c7d0bc"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<4.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + [[package]] name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -333,6 +469,77 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.11.0,<2.12.0" pyflakes = ">=3.1.0,<3.2.0" +[[package]] +name = "frozenlist" +version = "1.4.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab"}, + {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559"}, + {file = "frozenlist-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c"}, + {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b"}, + {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea"}, + {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326"}, + {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963"}, + {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300"}, + {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b"}, + {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8"}, + {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb"}, + {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9"}, + {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62"}, + {file = "frozenlist-1.4.0-cp310-cp310-win32.whl", hash = "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0"}, + {file = "frozenlist-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956"}, + {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95"}, + {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3"}, + {file = "frozenlist-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc"}, + {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839"}, + {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c"}, + {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f"}, + {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b"}, + {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b"}, + {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472"}, + {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01"}, + {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f"}, + {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467"}, + {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb"}, + {file = "frozenlist-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431"}, + {file = "frozenlist-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1"}, + {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3"}, + {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503"}, + {file = "frozenlist-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9"}, + {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf"}, + {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2"}, + {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc"}, + {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672"}, + {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919"}, + {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc"}, + {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79"}, + {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e"}, + {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781"}, + {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8"}, + {file = "frozenlist-1.4.0-cp38-cp38-win32.whl", hash = "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc"}, + {file = "frozenlist-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7"}, + {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf"}, + {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963"}, + {file = "frozenlist-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f"}, + {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1"}, + {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef"}, + {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87"}, + {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6"}, + {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087"}, + {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3"}, + {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d"}, + {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2"}, + {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a"}, + {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3"}, + {file = "frozenlist-1.4.0-cp39-cp39-win32.whl", hash = "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f"}, + {file = "frozenlist-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167"}, + {file = "frozenlist-1.4.0.tar.gz", hash = "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251"}, +] + [[package]] name = "humanize" version = "4.8.0" @@ -405,6 +612,31 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" +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.3" @@ -477,6 +709,18 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "main" +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 = "mmh3" version = "4.0.1" @@ -554,6 +798,90 @@ files = [ [package.extras] test = ["mypy (>=1.0)", "pytest (>=7.0.0)"] +[[package]] +name = "multidict" +version = "6.0.4" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, + {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, + {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, + {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, + {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, + {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, + {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, + {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, + {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, + {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, + {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, + {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, + {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, + {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, + {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, + {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, + {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, + {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, + {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, + {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, + {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, + {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, + {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, + {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, + {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, + {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, + {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, + {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, +] + [[package]] name = "nodeenv" version = "1.8.0" @@ -632,24 +960,6 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" -[[package]] -name = "prettytable" -version = "3.8.0" -description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "prettytable-3.8.0-py3-none-any.whl", hash = "sha256:03481bca25ae0c28958c8cd6ac5165c159ce89f7ccde04d5c899b24b68bb13b7"}, - {file = "prettytable-3.8.0.tar.gz", hash = "sha256:031eae6a9102017e8c7c7906460d150b7ed78b20fd1d8c8be4edaf88556c07ce"}, -] - -[package.dependencies] -wcwidth = "*" - -[package.extras] -tests = ["pytest", "pytest-cov", "pytest-lazy-fixture"] - [[package]] name = "pycodestyle" version = "2.11.0" @@ -674,6 +984,21 @@ files = [ {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, ] +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pytest" version = "7.4.0" @@ -697,6 +1022,26 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-aiohttp" +version = "1.0.4" +description = "Pytest plugin for aiohttp support" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"}, + {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"}, +] + +[package.dependencies] +aiohttp = ">=3.8.1" +pytest = ">=6.1.0" +pytest-asyncio = ">=0.17.2" + +[package.extras] +testing = ["coverage (==6.2)", "mypy (==0.931)"] + [[package]] name = "pytest-asyncio" version = "0.21.1" @@ -871,6 +1216,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "13.5.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, + {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, +] + +[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 = "scriv" version = "1.3.1" @@ -941,18 +1305,6 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] typing = ["mypy", "rich", "twisted"] -[[package]] -name = "telnetlib3" -version = "2.0.4" -description = "Python 3 asyncio Telnet server and client Protocol library" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "telnetlib3-2.0.4-py2.py3-none-any.whl", hash = "sha256:b3c0f984a7fb1b6ee16e6fdaa410c56389b0dc492174a99c6661b1ba4c9d457d"}, - {file = "telnetlib3-2.0.4.tar.gz", hash = "sha256:dbcbc16456a0e03a62431be7cfefff00515ab2f4ce2afbaf0d3a0e51a98c948d"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -1047,18 +1399,94 @@ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx- test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] -name = "wcwidth" -version = "0.2.6" -description = "Measures the displayed width of unicode strings in a terminal" +name = "yarl" +version = "1.9.2" +description = "Yet another URL library" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, - {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, + {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, + {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, + {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608"}, + {file = "yarl-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3"}, + {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528"}, + {file = "yarl-1.9.2-cp310-cp310-win32.whl", hash = "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3"}, + {file = "yarl-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb"}, + {file = "yarl-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7"}, + {file = "yarl-1.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7"}, + {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a"}, + {file = "yarl-1.9.2-cp311-cp311-win32.whl", hash = "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8"}, + {file = "yarl-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051"}, + {file = "yarl-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938"}, + {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04"}, + {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582"}, + {file = "yarl-1.9.2-cp37-cp37m-win32.whl", hash = "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b"}, + {file = "yarl-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4"}, + {file = "yarl-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417"}, + {file = "yarl-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc"}, + {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b"}, + {file = "yarl-1.9.2-cp38-cp38-win32.whl", hash = "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"}, + {file = "yarl-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8"}, + {file = "yarl-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955"}, + {file = "yarl-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3"}, + {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80"}, + {file = "yarl-1.9.2-cp39-cp39-win32.whl", hash = "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623"}, + {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, + {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, ] +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + [metadata] lock-version = "2.0" python-versions = "~3.10" -content-hash = "c841ddaa07e1fc9e00b757565e8719adde91d13cfd2f2298522d5de05c0b1f7f" +content-hash = "cf1448b95697493e332db65e8e587144a34cc9db3497a81d69f56e103277af01" diff --git a/pyproject.toml b/pyproject.toml index 72486f87..38e0fc66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,18 +46,19 @@ consulate-fc-nix-test = "1.1.0a1" humanize = "^4.8.0" mmh3 = "^4.0" packaging = "^23.1" -prettytable = "^3.6.0" python-lzo = "^1.15" requests = "^2.31.0" shortuuid = "^1.0.11" structlog = "^23.1.0" -telnetlib3 = "^2.0.0" tzlocal = "^5.0" colorama = "^0.4.6" +aiohttp = "^3.8.4" +rich = "^13.3.2" [tool.poetry.dev-dependencies] pre-commit = "^3.3.3" pytest = "^7.4.0" +pytest-aiohttp = "^1.0.4" pytest-asyncio = "^0.21.1" pytest-cache = "^1.0" pytest-cov = "^4.1.0" diff --git a/setup.py b/setup.py index e471d2dc..2fc71fae 100644 --- a/setup.py +++ b/setup.py @@ -55,16 +55,16 @@ def version(): install_requires=[ "consulate", "packaging", - "prettytable", "tzlocal", "PyYaml", "setuptools", "shortuuid", "python-lzo", - "telnetlib3>=1.0", "humanize", "mmh3", "structlog", + "aiohttp", + "rich", ], extras_require={ "test": [ diff --git a/src/backy/api.py b/src/backy/api.py new file mode 100644 index 00000000..97c99c3c --- /dev/null +++ b/src/backy/api.py @@ -0,0 +1,156 @@ +import datetime +import re +from json import JSONEncoder +from os import path as p +from typing import Any, List, Tuple + +from aiohttp import hdrs, web +from aiohttp.web_exceptions import ( + HTTPAccepted, + HTTPBadRequest, + HTTPForbidden, + HTTPNotFound, + HTTPPreconditionFailed, + HTTPPreconditionRequired, + HTTPServiceUnavailable, + HTTPUnauthorized, +) +from aiohttp.web_middlewares import middleware +from aiohttp.web_runner import AppRunner, TCPSite +from structlog.stdlib import BoundLogger + +import backy.daemon +from backy.backup import Backup +from backy.revision import Revision + + +class BackyJSONEncoder(JSONEncoder): + def default(self, o: Any) -> Any: + if hasattr(o, "to_dict"): + return o.to_dict() + elif isinstance(o, datetime.datetime): + return o.isoformat() + else: + super().default(o) + + +class BackyAPI: + daemon: "backy.daemon.BackyDaemon" + sites: dict[Tuple[str, int], TCPSite] + runner: AppRunner + tokens: dict + log: BoundLogger + + def __init__(self, daemon, log): + self.log = log.bind(subsystem="api") + self.daemon = daemon + self.sites = {} + self.app = web.Application( + middlewares=[self.log_conn, self.require_auth, self.to_json] + ) + self.app.add_routes( + [ + web.get("/v1/status", self.get_status), + web.post("/v1/reload", self.reload_daemon), + web.get("/v1/jobs", self.get_jobs), + # web.get("/v1/jobs/{job_name}", self.get_job), + web.post("/v1/jobs/{job_name}/run", self.run_job), + ] + ) + + async def start(self): + self.runner = AppRunner(self.app) + await self.runner.setup() + + async def stop(self): + await self.runner.cleanup() + self.sites = {} + + async def reconfigure( + self, tokens: dict[str, str], addrs: List[str], port: int + ): + self.log.debug("reconfigure") + self.tokens = tokens + endpoints = [(addr, port) for addr in addrs if addr and port] + for ep in endpoints: + if ep not in self.sites: + self.sites[ep] = site = TCPSite(self.runner, ep[0], ep[1]) + await site.start() + self.log.info("added-site", site=site.name) + for ep, site in self.sites.items(): + if ep not in endpoints: + await site.stop() + del self.sites[ep] + self.log.info("deleted-site", site=site.name) + + @middleware + async def log_conn(self, request: web.Request, handler): + request["log"] = self.log.bind( + path=request.path, query=request.query_string + ) + try: + resp = await handler(request) + except Exception as e: + if not isinstance(e, web.HTTPException): + request["log"].exception("error-handling-request") + else: + request["log"].debug( + "request-result", status_code=e.status_code + ) + raise + request["log"].debug("request-result", response=resp.body) + return resp + + @middleware + async def require_auth(self, request: web.Request, handler): + request["log"].debug("new-conn") + token = request.headers.get(hdrs.AUTHORIZATION, "") + if not token.startswith("Bearer "): + request["log"].info("auth-invalid-token") + raise HTTPUnauthorized() + token = token.removeprefix("Bearer ") + if len(token) < 3: # avoid potential truthiness edge cases + request["log"].info("auth-token-too-short") + raise HTTPUnauthorized() + client = self.tokens.get(token, None) + if not client: + request["log"].info("auth-token-unknown") + raise HTTPUnauthorized() + request["client"] = client + request["log"] = request["log"].bind(client=client) + request["log"].debug("auth-passed") + return await handler(request) + + @middleware + async def to_json(self, request: web.Request, handler): + resp = await handler(request) + if isinstance(resp, web.Response): + return resp + elif resp is None: + raise web.HTTPNoContent() + else: + return web.json_response(resp, dumps=BackyJSONEncoder().encode) + + async def get_status(self, request: web.Request): + filter = request.query.get("filter", "") + if filter: + filter = re.compile(filter) + return self.daemon.status(filter) + + async def reload_daemon(self, request: web.Request): + self.daemon.reload() + + async def get_jobs(self, request: web.Request): + return list(self.daemon.jobs.values()) + + async def get_job(self, request: web.Request): + try: + name = request.match_info.get("job_name", None) + return self.daemon.jobs[name] + except KeyError: + raise HTTPNotFound() + + async def run_job(self, request: web.Request): + j = await self.get_job(request) + j.run_immediately.set() + raise HTTPAccepted() diff --git a/src/backy/client.py b/src/backy/client.py new file mode 100644 index 00000000..9bd738d9 --- /dev/null +++ b/src/backy/client.py @@ -0,0 +1,210 @@ +import datetime +from asyncio import get_running_loop + +import aiohttp +import humanize +from aiohttp import ClientTimeout, TCPConnector, hdrs +from aiohttp.web_exceptions import HTTPNotFound +from rich import print as rprint +from rich.table import Column, Table +from structlog.stdlib import BoundLogger + +import backy.backup +from backy.revision import Revision +from backy.utils import format_datetime_local + + +class APIClient: + log: BoundLogger + server_name: str + session: aiohttp.ClientSession + + def __init__( + self, + server_name: str, + url: str, + token: str, + log, + ): + assert get_running_loop().is_running() + self.log = log.bind(subsystem="APIClient") + self.server_name = server_name + self.session = aiohttp.ClientSession( + url, + headers={hdrs.AUTHORIZATION: "Bearer " + token}, + raise_for_status=True, + timeout=ClientTimeout(30, connect=10), + ) + + @classmethod + def from_conf(cls, server_name, conf, *args, **kwargs): + return cls( + server_name, + conf["url"], + conf["token"], + *args, + **kwargs, + ) + + async def fetch_status(self, filter=""): + async with self.session.get( + "/v1/status", params={"filter": filter} + ) as response: + jobs = await response.json() + for job in jobs: + if job["last_time"]: + job["last_time"] = datetime.datetime.fromisoformat( + job["last_time"] + ) + if job["next_time"]: + job["next_time"] = datetime.datetime.fromisoformat( + job["next_time"] + ) + return jobs + + async def reload_daemon(self): + async with self.session.post(f"/v1/reload") as response: + return + + async def get_jobs(self): + async with self.session.get("/v1/jobs") as response: + return await response.json() + + async def run_job(self, name): + async with self.session.post(f"/v1/jobs/{name}/run") as response: + return + + async def close(self): + await self.session.close() + + async def __aenter__(self) -> "APIClient": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + +class CLIClient: + api: APIClient + log: BoundLogger + + def __init__(self, apiclient, log): + self.api = apiclient + self.log = log.bind(subsystem="CLIClient") + + async def __aenter__(self) -> "CLIClient": + await self.api.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.api.__aexit__(exc_type, exc_val, exc_tb) + + async def jobs(self, filter_re=""): + """List status of all known jobs. Optionally filter by regex.""" + + tz = format_datetime_local(None)[1] + + t = Table( + "Job", + "SLA", + "SLA overdue", + "Status", + f"Last Backup ({tz})", + "Last Tags", + Column("Last Duration", justify="right"), + f"Next Backup ({tz})", + "Next Tags", + ) + + jobs = await self.api.fetch_status(filter_re) + jobs.sort(key=lambda j: j["job"]) + for job in jobs: + overdue = ( + humanize.naturaldelta(job["sla_overdue"]) + if job["sla_overdue"] + else "-" + ) + last_duration = ( + humanize.naturaldelta(job["last_duration"]) + if job["last_duration"] + else "-" + ) + last_time = format_datetime_local(job["last_time"])[0] + next_time = format_datetime_local(job["next_time"])[0] + + t.add_row( + job["job"], + job["sla"], + overdue, + job["status"], + last_time, + job["last_tags"], + last_duration, + next_time, + job["next_tags"], + ) + + rprint(t) + print("{} jobs shown".format(len(jobs))) + + async def status(self): + """Show job status overview""" + t = Table("Status", "#") + state_summary = {} + jobs = await self.api.get_jobs() + for job in jobs: + state_summary.setdefault(job["status"], 0) + state_summary[job["status"]] += 1 + + for state in sorted(state_summary): + t.add_row(state, str(state_summary[state])) + rprint(t) + + async def run(self, job: str): + """Show job status overview""" + try: + await self.api.run_job(job) + except HTTPNotFound: + print("Unknown job {}".format(job)) + return + print("Triggered immediate run for {}".format(job)) + + async def runall(self): + """Show job status overview""" + jobs = await self.api.get_jobs() + for job in jobs: + await self.run(job["name"]) + + async def reload(self): + """Reload the configuration.""" + print("Triggering daemon reload.") + await self.api.reload_daemon() + print("Daemon configuration reloaded.") + + async def check(self): + status = await self.api.fetch_status() + + exitcode = 0 + + for job in status: + log = self.log.bind(job_name=job["job"]) + if job["manual_tags"]: + log.info( + "check-manual-tags", + manual_tags=job["manual_tags"], + ) + if job["sla"] != "OK": + log.critical( + "check-sla-violation", + last_time=str(job["last_time"]), + sla_overdue=job["sla_overdue"], + ) + exitcode = max(exitcode, 2) + if job["quarantine_reports"]: + log.warning( + "check-quarantined", reports=job["quarantine_reports"] + ) + exitcode = max(exitcode, 1) + + self.log.info("check-exit", exitcode=exitcode, jobs=len(status)) + raise SystemExit(exitcode) diff --git a/src/backy/daemon.py b/src/backy/daemon.py index 0f62e130..d7ff4c9e 100644 --- a/src/backy/daemon.py +++ b/src/backy/daemon.py @@ -2,24 +2,20 @@ import fcntl import os import os.path as p -import re import shutil import signal import sys import time -from typing import IO, Optional +from typing import IO, List, Optional -import humanize -import pkg_resources -import prettytable -import telnetlib3 import yaml from structlog.stdlib import BoundLogger +from .api import BackyAPI from .revision import filter_manual_tags from .schedule import Schedule from .scheduler import Job -from .utils import SafeFile, format_datetime_local, has_recent_changes +from .utils import has_recent_changes daemon: "BackyDaemon" @@ -28,10 +24,11 @@ class BackyDaemon(object): # config defaults, will be overriden from config file worker_limit: int = 1 base_dir: str - status_file: str - status_interval: int = 30 - telnet_addrs: str = "::1, 127.0.0.1" - telnet_port: int = 6023 + api_addrs: List[str] + api_port: int = 6023 + api_tokens: dict[str, str] + api_cli_default: dict + peers: dict[str, dict] config_file: str config: dict schedules: dict[str, Schedule] @@ -40,6 +37,7 @@ class BackyDaemon(object): backup_semaphores: dict[str, asyncio.BoundedSemaphore] log: BoundLogger _lock: Optional[IO] = None + reload_api: asyncio.Event loop: Optional[asyncio.AbstractEventLoop] = None @@ -51,6 +49,11 @@ def __init__(self, config_file, log): self.backup_semaphores = {} self.jobs = {} self._lock = None + self.reload_api = asyncio.Event() + self.api_addrs = ["::1", "127.0.0.1"] + self.api_tokens = {} + self.api_cli_default = {} + self.peers = {} def _read_config(self): if not p.exists(self.config_file): @@ -63,12 +66,25 @@ def _read_config(self): g = self.config.get("global", {}) self.worker_limit = int(g.get("worker-limit", type(self).worker_limit)) self.base_dir = g.get("base-dir") - self.status_file = g.get("status-file", p.join(self.base_dir, "status")) - self.status_interval = int( - g.get("status-interval", type(self).status_interval) - ) - self.telnet_addrs = g.get("telnet-addrs", type(self).telnet_addrs) - self.telnet_port = int(g.get("telnet-port", type(self).telnet_port)) + + self.peers = self.config.get("peers", {}) + + api = self.config.get("api", {}) + self.api_addrs = [ + a.strip() for a in api.get("addrs", "::1, 127.0.0.1").split(",") + ] + + self.api_port = int(api.get("port", type(self).api_port)) + self.api_tokens = api.get("tokens", {}) + self.api_cli_default = api.get("cli-default", {}) + if ( + self.api_addrs + and self.api_port + and "url" not in self.api_cli_default + ): + self.api_cli_default[ + "url" + ] = f"http://{self.api_addrs[0]}:{self.api_port}" new = {} for name, config in self.config["schedules"].items(): @@ -81,11 +97,11 @@ def _read_config(self): self.log.info( "read-config", - status_interval=self.status_interval, - status_file=self.status_file, worker_limit=self.worker_limit, base_dir=self.base_dir, schedules=", ".join(self.schedules), + api_addrs=self.api_addrs, + api_port=self.api_port, ) def _apply_config(self): @@ -148,7 +164,6 @@ def start(self, loop): self._apply_config() - loop.create_task(self.save_status_file(), name="save-status") loop.create_task(self.purge_old_files(), name="purge-old-files") loop.create_task(self.shutdown_loop(), name="shutdown-cleanup") @@ -178,23 +193,33 @@ def reload(self): try: self._read_config() self._apply_config() + self.reload_api.set() self.log.info("reloading-finished") except Exception: self.log.critical("error-reloading", exc_info=True) self.terminate() raise - def telnet_server(self): - """Starts to listen on all configured telnet addresses.""" - assert self.loop, "cannot start telnet server without event loop" - for addr in (a.strip() for a in self.telnet_addrs.split(",")): - self.log.info("telnet-starting", addr=addr, port=self.telnet_port) - server = telnetlib3.create_server( - host=addr, port=self.telnet_port, shell=telnet_server_shell - ) - self.loop.create_task( - server, name=f"telnet-server-{addr}-{self.telnet_port}" + def api_server(self): + assert self.loop, "cannot start api server without event loop" + self.loop.create_task(self.api_server_loop(), name="api_server_loop") + + async def api_server_loop(self): + try: + self.log.info( + "api-starting", addrs=self.api_addrs, port=self.api_port ) + api = BackyAPI(self, self.log) + await api.start() + while True: + self.log.info("api-reconfigure") + await api.reconfigure( + self.api_tokens, self.api_addrs, self.api_port + ) + self.reload_api.clear() + await self.reload_api.wait() + except Exception: + self.log.exception("api_server_loop") def terminate(self): self.log.info("terminating") @@ -263,28 +288,6 @@ def status(self, filter_re=None): ) return result - def _write_status_file(self): - status = self.status() - with SafeFile(self.status_file, sync=False) as tmp: - tmp.protected_mode = 0o644 - tmp.open_new("w") - yaml.safe_dump(status, tmp.f) - for job in status: - if not job["sla_overdue"]: - continue - overdue = humanize.naturaldelta(job["sla_overdue"]) - self.log.warning( - "sla-violation", job_name=job["job"], overdue=overdue - ) - - async def save_status_file(self): - while True: - try: - self._write_status_file() - except Exception: # pragma: no cover - self.log.exception("save-status-exception") - await asyncio.sleep(self.status_interval) - async def purge_old_files(self): # `stat` and other file system access things are _not_ # properly async, we might want to spawn those off into a separate @@ -302,225 +305,6 @@ async def purge_old_files(self): self.log.info("purge-finished") await asyncio.sleep(24 * 60 * 60) - def check(self): - try: - self._read_config() - except RuntimeError: - sys.exit(1) - - if not p.exists(self.status_file): - self.log.error("check-no-status-file", status_file=self.status_file) - sys.exit(3) - - # The output should be relatively new. Let's say 5 min max. - s = os.stat(self.status_file) - if time.time() - s.st_mtime > 5 * 60: - self.log.critical( - "check-old-status-file", age=time.time() - s.st_mtime - ) - sys.exit(2) - - exitcode = 0 - - with open(self.status_file, encoding="utf-8") as f: - status = yaml.safe_load(f) - - for job in status: - log = self.log.bind(job_name=job["job"]) - if job["manual_tags"]: - log.info( - "check-manual-tags", - manual_tags=job["manual_tags"], - ) - if job["sla"] != "OK": - log.critical( - "check-sla-violation", - last_time=str(job["last_time"]), - sla_overdue=job["sla_overdue"], - ) - exitcode = max(exitcode, 2) - if job["quarantine_reports"]: - log.warning( - "check-quarantined", reports=job["quarantine_reports"] - ) - exitcode = max(exitcode, 1) - - self.log.info("check-exit", exitcode=exitcode, jobs=len(status)) - sys.exit(exitcode) - - -async def telnet_server_shell(reader, writer): - """ - A default telnet shell, appropriate for use with telnetlib3.create_server. - This shell provides a very simple REPL, allowing introspection and state - toggling of the connected client session. - This function is a :func:`~asyncio.coroutine`. - """ - from telnetlib3.server_shell import CR, LF, readline - - writer.write( - "backy {}".format(pkg_resources.require("backy")[0].version) + CR + LF - ) - writer.write("Ready." + CR + LF) - - linereader = readline(reader, writer) - linereader.send(None) - - shell = SchedulerShell(writer=writer) - - command = None - while True: - if command: - writer.write(CR + LF) - writer.write("backy> ") - command = None - while command is None: - # TODO: use reader.readline() - try: - inp = await reader.read(1) - except Exception: - inp = None # Likely a timeout - if not inp: - return - command = linereader.send(inp) - command = command.strip() - writer.write(CR + LF) - if command == "quit": - writer.write("Goodbye." + CR + LF) - break - elif command == "help": - writer.write("jobs [filter], status, run , runall, reload") - elif command.startswith("jobs"): - if " " in command: - _, filter_re = command.split(" ", maxsplit=1) - else: - filter_re = None - shell.jobs(filter_re) - elif command == "reload": - shell.reload() - elif command == "status": - shell.status() - elif command.startswith("runall"): - shell.runall() - elif command.startswith("run"): - if " " in command: - _, job = command.split(" ", maxsplit=1) - else: - job = None - shell.run(job) - elif command: - writer.write("no such command.") - writer.close() - - -class SchedulerShell(object): - writer = None - - def __init__(self, writer): - self.writer = writer - - def jobs(self, filter_re=None): - """List status of all known jobs. Optionally filter by regex.""" - filter_re = re.compile(filter_re) if filter_re else None - - tz = format_datetime_local(None)[1] - - t = prettytable.PrettyTable( - [ - "Job", - "SLA", - "SLA overdue", - "Status", - f"Last Backup ({tz.zone})", - "Last Tags", - "Last Duration", - f"Next Backup ({tz.zone})", - "Next Tags", - ] - ) - t.align = "l" - t.align["Last Dur"] = "r" - t.sortby = "Job" - - jobs = daemon.status(filter_re) - for job in jobs: - overdue = ( - humanize.naturaldelta(job["sla_overdue"]) - if job["sla_overdue"] - else "-" - ) - last_duration = ( - humanize.naturaldelta(job["last_duration"]) - if job["last_duration"] - else "-" - ) - last_time = format_datetime_local(job["last_time"])[0] - next_time = format_datetime_local(job["next_time"])[0] - - t.add_row( - [ - job["job"], - job["sla"], - overdue, - job["status"], - last_time, - job["last_tags"], - last_duration, - next_time, - job["next_tags"], - ] - ) - - self.writer.write(t.get_string().replace("\n", "\r\n") + "\r\n") - self.writer.write("{} jobs shown".format(len(jobs))) - - def status(self): - """Show job status overview""" - t = prettytable.PrettyTable(["Status", "#"]) - state_summary = {} - for job in daemon.jobs.values(): - state_summary.setdefault(job.status, 0) - state_summary[job.status] += 1 - - for state in sorted(state_summary): - t.add_row([state, state_summary[state]]) - self.writer.write(t.get_string().replace("\n", "\r\n")) - - def run(self, job): - """Show job status overview""" - try: - job = daemon.jobs[job] - except KeyError: - self.writer.write("Unknown job {}".format(job)) - return - if not hasattr(job, "_task"): - self.writer.write("Task not ready. Try again later.") - return - job.run_immediately.set() - self.writer.write("Triggered immediate run for {}".format(job.name)) - - def runall(self): - """Show job status overview""" - for job in daemon.jobs.values(): - if not hasattr(job, "_task"): - self.writer.write( - "{} not ready. Try again later.".format(job.name) - ) - continue - job.run_immediately.set() - self.writer.write("Triggered immediate run for {}".format(job.name)) - - def reload(self): - """Reload the configuration.""" - self.writer.write("Triggering daemon reload.\r\n") - daemon.reload() - self.writer.write("Daemon configuration reloaded.\r\n") - - -def check(config_file, log: BoundLogger): # pragma: no cover - daemon = BackyDaemon(config_file, log) - daemon.check() - def main(config_file, log: BoundLogger): # pragma: no cover global daemon @@ -528,5 +312,5 @@ def main(config_file, log: BoundLogger): # pragma: no cover loop = asyncio.get_event_loop() daemon = BackyDaemon(config_file, log) daemon.start(loop) - daemon.telnet_server() + daemon.api_server() daemon.run_forever() diff --git a/src/backy/main.py b/src/backy/main.py index 57edc6df..8eb2df75 100644 --- a/src/backy/main.py +++ b/src/backy/main.py @@ -1,6 +1,7 @@ # -*- encoding: utf-8 -*- import argparse +import asyncio import datetime import errno import sys @@ -9,7 +10,8 @@ import humanize import structlog import tzlocal -from prettytable import PrettyTable +from rich import print as rprint +from rich.table import Column, Table from structlog.stdlib import BoundLogger import backy.backup @@ -17,6 +19,7 @@ from backy.utils import format_datetime_local from . import logging +from .client import APIClient, CLIClient def valid_date(s): @@ -44,12 +47,14 @@ def status(self): total_bytes = 0 tz = tzlocal.get_localzone() - t = PrettyTable( - [f"Date ({tz})", "ID", "Size", "Duration", "Tags", "Trust"] + t = Table( + f"Date ({tz})", + "ID", + Column("Size", justify="right"), + Column("Duration", justify="right"), + "Tags", + "Trust", ) - t.align = "l" - t.align["Size"] = "r" - t.align["Durat"] = "r" for r in b.history: total_bytes += r.stats.get("bytes_written", 0) @@ -60,19 +65,17 @@ def status(self): duration = "-" t.add_row( - [ - format_datetime_local(r.timestamp)[0], - r.uuid, - humanize.naturalsize( - r.stats.get("bytes_written", 0), binary=True - ), - duration, - ",".join(r.tags), - r.trust.value, - ] + format_datetime_local(r.timestamp)[0], + r.uuid, + humanize.naturalsize( + r.stats.get("bytes_written", 0), binary=True + ), + duration, + ",".join(r.tags), + r.trust.value, ) - print(t) + rprint(t) print( "{} revisions containing {} data (estimated)".format( @@ -104,9 +107,6 @@ def forget(self, revision): def scheduler(self, config): backy.daemon.main(config, self.log) - def check(self, config): - backy.daemon.check(config, self.log) - def purge(self): b = backy.backup.Backup(self.path, self.log) b.purge() @@ -123,6 +123,42 @@ def verify(self, revision): b = backy.backup.Backup(self.path, self.log) b.verify(revision) + def client(self, config, peer, url, token, apifunc, **kwargs): + async def run(): + if url and token: + api = APIClient("", url, token, self.log) + else: + d = backy.daemon.BackyDaemon(config, self.log) + d._read_config() + if peer: + api = APIClient.from_conf(peer, d.peers[peer], self.log) + else: + api = APIClient.from_conf( + "", d.api_cli_default, self.log + ) + async with CLIClient(api, self.log) as c: + await getattr(c, apifunc)(**kwargs) + + asyncio.run(run()) + + def tags(self, action, autoremove, expect, revision, tags, force): + tags = set(t.strip() for t in tags.split(",")) + if expect is not None: + expect = set(t.strip() for t in expect.split(",")) + b = backy.backup.Backup(self.path, self.log) + b.tags( + action, + revision, + tags, + expect=expect, + autoremove=autoremove, + force=force, + ) + + def expire(self): + b = backy.backup.Backup(self.path, self.log) + b.expire() + def setup_argparser(): parser = argparse.ArgumentParser( @@ -140,7 +176,7 @@ def setup_argparser(): default=argparse.SUPPRESS, help=( "file name to write log output in. " - "(default: /var/log/backy.log for `scheduler` and `check`, " + "(default: /var/log/backy.log for `scheduler`, " "$backupdir/backy.log otherwise)" ), ) @@ -157,6 +193,63 @@ def setup_argparser(): subparsers = parser.add_subparsers() + # CLIENT + client = subparsers.add_parser( + "client", + help="""\ +Query the api +""", + ) + g = client.add_argument_group() + g.add_argument("-c", "--config", default="/etc/backy.conf") + g.add_argument("-p", "--peer") + g = client.add_argument_group() + g.add_argument("--url") + g.add_argument("--token") + client.set_defaults(func="client") + client_parser = client.add_subparsers() + + # CLIENT jobs + p = client_parser.add_parser("jobs", help="List status of all known jobs") + p.add_argument( + "filter_re", + default="", + metavar="[filter]", + nargs="?", + help="Optional job filter regex", + ) + p.set_defaults(apifunc="jobs") + + # CLIENT status + p = client_parser.add_parser("status", help="Show job status overview") + p.set_defaults(apifunc="status") + + # CLIENT run + p = client_parser.add_parser( + "run", help="Trigger immediate run for one job" + ) + p.add_argument("job", metavar="", help="Name of the job to run") + p.set_defaults(apifunc="run") + + # CLIENT runall + p = client_parser.add_parser( + "runall", help="Trigger immediate run for all jobs" + ) + p.set_defaults(apifunc="runall") + + # CLIENT reload + p = client_parser.add_parser("reload", help="Reload the configuration") + p.set_defaults(apifunc="reload") + + # CLIENT check + p = client_parser.add_parser( + "check", + help="""\ +Check whether all jobs adhere to their schedules' SLA. +""", + ) + p.set_defaults(apifunc="check") + # BACKUP p = subparsers.add_parser( "backup", @@ -230,16 +323,6 @@ def setup_argparser(): p.set_defaults(func="scheduler") p.add_argument("-c", "--config", default="/etc/backy.conf") - # SCHEDULE CHECK - p = subparsers.add_parser( - "check", - help="""\ -Check whether all jobs adhere to their schedules' SLA. -""", - ) - p.set_defaults(func="check") - p.add_argument("-c", "--config", default="/etc/backy.conf") - # DISTRUST p = subparsers.add_parser( "distrust", @@ -317,18 +400,22 @@ def main(): if not hasattr(args, "logfile"): args.logfile = None - is_daemon = args.func == "scheduler" or args.func == "check" - default_logfile = ( - Path("/var/log/backy.log") - if is_daemon - else args.backupdir / "backy.log" - ) + match args.func: + case "scheduler": + default_logfile = Path("/var/log/backy.log") + case "client": + default_logfile = None + case _: + default_logfile = args.backupdir / "backy.log" # Logging logging.init_logging( args.verbose, args.logfile or default_logfile, - default_job_name="-" if is_daemon else "", + default_job_name="-" + if args.func == "scheduler" + or (args.func == "client" and args.apifunc == "check") + else "", ) log = structlog.stdlib.get_logger(subsystem="command") log.debug("invoked", args=" ".join(sys.argv)) diff --git a/src/backy/schedule.py b/src/backy/schedule.py index 6eea98d9..368bc0dc 100644 --- a/src/backy/schedule.py +++ b/src/backy/schedule.py @@ -61,6 +61,9 @@ def configure(self, config): for tag, spec in self.schedule.items(): self.schedule[tag]["interval"] = parse_duration(spec["interval"]) + def to_dict(self): + return self.config + def next(self, relative, spread, archive): time, tags = ideal_time, ideal_tags = self._next_ideal(relative, spread) missed_tags = self._missed(archive) diff --git a/src/backy/scheduler.py b/src/backy/scheduler.py index e09b6cfa..5bb00a1a 100644 --- a/src/backy/scheduler.py +++ b/src/backy/scheduler.py @@ -113,6 +113,14 @@ def update_config(self): if p.exists(config) and filecmp.cmp(config, f.name): raise ValueError("not changed") + def to_dict(self): + return { + "name": self.name, + "status": self.status, + "source": self.source, + "schedule": self.schedule.to_dict(), + } + async def _wait_for_deadline(self): self.update_status("waiting for deadline") trigger = await time_or_event(self.next_time, self.run_immediately) diff --git a/src/backy/tests/test_backy.py b/src/backy/tests/test_backy.py index 7ee3eb10..1d5df426 100644 --- a/src/backy/tests/test_backy.py +++ b/src/backy/tests/test_backy.py @@ -141,22 +141,31 @@ def test_smoketest_external(): Diffing restore_state2.img against img_state2.img. Success. Restoring img_state1.img from level 3. Done. Diffing restore_state1.img against img_state1.img. Success. -+----------------------+------------------------+-----------+----------+-------------+---------+ -| Date (...) | ID | Size | Duration | Tags | Trust | -+----------------------+------------------------+-----------+----------+-------------+---------+ -| ... | ... | 512.0 KiB | a moment | manual:test | trusted | -| ... | ... | 512.0 KiB | a moment | daily | trusted | -| ... | ... | 512.0 KiB | a moment | test | trusted | -| ... | ... | 512.0 KiB | a moment | manual:test | trusted | -+----------------------+------------------------+-----------+----------+-------------+---------+ +┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━┓ +┃ Date ┃ ┃ ┃ ┃ ┃ ┃ +┃ ... ┃ ID ┃ Size ┃ Duration ┃ Tags ┃ Trust ┃ +┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━┩ +│ ... │ ... │ 512.0 KiB │ a moment │ manual:test │ trusted │ +│ ... │ │ │ │ │ │ +│ ... │ ... │ 512.0 KiB │ a moment │ daily │ trusted │ +│ ... │ │ │ │ │ │ +│ ... │ ... │ 512.0 KiB │ a moment │ test │ trusted │ +│ ... │ │ │ │ │ │ +│ ... │ ... │ 512.0 KiB │ a moment │ manual:test │ trusted │ +│ ... │ │ │ │ │ │ +└───────────────┴───────────────┴───────────┴──────────┴─────────────┴─────────┘ 4 revisions containing 2.0 MiB data (estimated) -+----------------------+------------------------+-----------+----------+-------------+---------+ -| Date (...) | ID | Size | Duration | Tags | Trust | -+----------------------+------------------------+-----------+----------+-------------+---------+ -| ... | ... | 512.0 KiB | a moment | manual:test | trusted | -| ... | ... | 512.0 KiB | a moment | test | trusted | -| ... | ... | 512.0 KiB | a moment | manual:test | trusted | -+----------------------+------------------------+-----------+----------+-------------+---------+ +┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━┓ +┃ Date ┃ ┃ ┃ ┃ ┃ ┃ +┃ ... ┃ ID ┃ Size ┃ Duration ┃ Tags ┃ Trust ┃ +┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━┩ +│ ... │ ... │ 512.0 KiB │ a moment │ manual:test │ trusted │ +│ ... │ │ │ │ │ │ +│ ... │ ... │ 512.0 KiB │ a moment │ test │ trusted │ +│ ... │ │ │ │ │ │ +│ ... │ ... │ 512.0 KiB │ a moment │ manual:test │ trusted │ +│ ... │ │ │ │ │ │ +└───────────────┴───────────────┴───────────┴──────────┴─────────────┴─────────┘ 3 revisions containing 1.5 MiB data (estimated) """ ) diff --git a/src/backy/tests/test_client.py b/src/backy/tests/test_client.py new file mode 100644 index 00000000..f44f7dd4 --- /dev/null +++ b/src/backy/tests/test_client.py @@ -0,0 +1,297 @@ +import datetime +from unittest import mock + +import pytest +from aiohttp import ClientResponseError, hdrs +from aiohttp.web_exceptions import HTTPUnauthorized + +from backy import utils +from backy.api import BackyAPI +from backy.client import APIClient, CLIClient +from backy.revision import Revision +from backy.tests import Ellipsis + +from ..quarantine import QuarantineReport +from .test_daemon import daemon + + +@pytest.fixture(autouse=True) +def configure_logging(setup_structlog): + setup_structlog.default_job_name = "-" + + +@pytest.fixture +async def api(daemon, log): + api = BackyAPI(daemon, log) + api.tokens = daemon.api_tokens + return api + + +@pytest.mark.parametrize( + "token", + [ + None, + "", + "asdf", + "Bearer", + "Bearer ", + "Bearer a", + "Bearer asdf", + "Bearer cli", + ], +) +@pytest.mark.parametrize("endpoint", ["/invalid", "/status"]) +@pytest.mark.parametrize( + "method", [hdrs.METH_GET, hdrs.METH_POST, hdrs.METH_TRACE] +) +async def test_api_wrong_token(api, token, method, endpoint, aiohttp_client): + client = await aiohttp_client( + api.app, + headers={hdrs.AUTHORIZATION: token} if token is not None else {}, + raise_for_status=True, + ) + with pytest.raises(ClientResponseError) as e: + await client.request(method, endpoint) + assert e.value.status == HTTPUnauthorized.status_code + + +@pytest.fixture +async def api_client(api, aiohttp_client, log): + client = await aiohttp_client( + api.app, + headers={hdrs.AUTHORIZATION: "Bearer testtoken"}, + raise_for_status=True, + ) + api_client = APIClient("", "http://localhost:0", "", log) + await api_client.session.close() + api_client.session = client + return api_client + + +@pytest.fixture +async def cli_client(api_client, log): + return CLIClient(api_client, log) + + +async def test_cli_jobs(cli_client, capsys): + await cli_client.jobs() + out, err = capsys.readouterr() + assert ( + Ellipsis( + """\ +┏━━━━━━━━┳━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ +┃ ┃ ┃ ┃ ┃ Last ┃ ┃ ┃ Next ┃ ┃ +┃ ┃ ┃ SLA ┃ ┃ Backup ┃ Last ┃ Last ┃ Backup ┃ Next ┃ +┃ Job ┃ SLA ┃ overd… ┃ Status ┃ ... ┃ Tags ┃ Durat… ┃ ... ┃ Tags ┃ +┡━━━━━━━━╇━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ +│ foo00 │ OK │ - │ waiti… │ - │ │ - │ ... │ daily │ +│ │ │ │ for │ │ │ │ ... │ │ +│ │ │ │ deadl… │ │ │ │ │ │ +│ test01 │ OK │ - │ waiti… │ - │ │ - │ ... │ daily │ +│ │ │ │ for │ │ │ │ ... │ │ +│ │ │ │ deadl… │ │ │ │ │ │ +└────────┴─────┴────────┴────────┴────────┴────────┴────────┴─────────┴────────┘ +2 jobs shown +""" + ) + == out + ) + + await cli_client.jobs(filter_re="test01") + out, err = capsys.readouterr() + assert ( + Ellipsis( + """\ +┏━━━━━━━━┳━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ +┃ ┃ ┃ ┃ ┃ Last ┃ ┃ ┃ Next ┃ ┃ +┃ ┃ ┃ SLA ┃ ┃ Backup ┃ Last ┃ Last ┃ Backup ┃ Next ┃ +┃ Job ┃ SLA ┃ overd… ┃ Status ┃ ... ┃ Tags ┃ Durat… ┃ ... ┃ Tags ┃ +┡━━━━━━━━╇━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ +│ test01 │ OK │ - │ waiti… │ - │ │ - │ ... │ daily │ +│ │ │ │ for │ │ │ │ ... │ │ +│ │ │ │ deadl… │ │ │ │ │ │ +└────────┴─────┴────────┴────────┴────────┴────────┴────────┴─────────┴────────┘ +1 jobs shown +""" + ) + == out + ) + + await cli_client.jobs(filter_re="asdf") + out, err = capsys.readouterr() + assert ( + Ellipsis( + """\ +┏━━━━━┳━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ +┃ ┃ ┃ ┃ ┃ Last ┃ ┃ ┃ Next ┃ ┃ +┃ ┃ ┃ SLA ┃ ┃ Backup ┃ Last ┃ Last ┃ Backup ┃ Next ┃ +┃ Job ┃ SLA ┃ overdue ┃ Status ┃ ... ┃ Tags ┃ Durat… ┃ ... ┃ Tags ┃ +┡━━━━━╇━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ +└─────┴─────┴─────────┴────────┴─────────┴─────────┴────────┴─────────┴────────┘ +0 jobs shown +""" + ) + == out + ) + + +async def test_cli_status(cli_client, capsys): + await cli_client.status() + out, err = capsys.readouterr() + assert ( + """\ +┏━━━━━━━━━━━━━━━━━━━━━━┳━━━┓ +┃ Status ┃ # ┃ +┡━━━━━━━━━━━━━━━━━━━━━━╇━━━┩ +│ waiting for deadline │ 2 │ +└──────────────────────┴───┘ +""" + == out + ) + + +async def test_cli_run(daemon, cli_client, capsys, monkeypatch): + run = mock.Mock() + monkeypatch.setattr(daemon.jobs["test01"].run_immediately, "set", run) + + await cli_client.run("test01") + + run.assert_called_once() + out, err = capsys.readouterr() + assert ( + """\ +Triggered immediate run for test01 +""" + == out + ) + + +async def test_cli_runall(daemon, cli_client, capsys, monkeypatch): + run1 = mock.Mock() + run2 = mock.Mock() + monkeypatch.setattr(daemon.jobs["test01"].run_immediately, "set", run1) + monkeypatch.setattr(daemon.jobs["foo00"].run_immediately, "set", run2) + + await cli_client.runall() + + run1.assert_called_once() + run2.assert_called_once() + out, err = capsys.readouterr() + assert ( + """\ +Triggered immediate run for test01 +Triggered immediate run for foo00 +""" + == out + ) + + +async def test_cli_reload(daemon, cli_client, capsys, monkeypatch): + reload = mock.Mock() + monkeypatch.setattr(daemon, "reload", reload) + + await cli_client.reload() + + reload.assert_called_once() + out, err = capsys.readouterr() + assert ( + """\ +Triggering daemon reload. +Daemon configuration reloaded. +""" + == out + ) + + +async def test_cli_check_ok(daemon, cli_client): + utils.log_data = "" + try: + await cli_client.check() + except SystemExit as e: + assert e.code == 0 + assert ( + Ellipsis( + """\ +... D - api/new-conn path='/v1/status' query='filter=' +... D - api/auth-passed client='cli' path='/v1/status' query='filter=' +... D - api/request-result client='cli' path='/v1/status' query='filter=' response=... +... I - CLIClient/check-exit exitcode=0 jobs=2 +""" + ) + == utils.log_data + ) + + +async def test_cli_check_too_old(daemon, clock, cli_client, log): + job = daemon.jobs["test01"] + revision = Revision(job.backup, log, "1") + revision.timestamp = utils.now() - datetime.timedelta(hours=48) + revision.stats["duration"] = 60.0 + revision.materialize() + + utils.log_data = "" + try: + await cli_client.check() + except SystemExit as e: + assert e.code == 2 + assert ( + Ellipsis( + """\ +... D - api/new-conn path='/v1/status' query='filter=' +... D - api/auth-passed client='cli' path='/v1/status' query='filter=' +... D - api/request-result client='cli' path='/v1/status' query='filter=' response=... +... C test01 CLIClient/check-sla-violation last_time='2015-08-30 07:06:47+00:00' sla_overdue=172800.0 +... I - CLIClient/check-exit exitcode=2 jobs=2 +""" + ) + == utils.log_data + ) + + +async def test_cli_check_manual_tags(daemon, cli_client, log): + job = daemon.jobs["test01"] + revision = Revision.create(job.backup, {"manual:test"}, log) + revision.timestamp = utils.now() + revision.stats["duration"] = 60.0 + revision.materialize() + + utils.log_data = "" + try: + await cli_client.check() + except SystemExit as e: + assert e.code == 0 + assert ( + Ellipsis( + """\ +... D - api/new-conn path='/v1/status' query='filter=' +... D - api/auth-passed client='cli' path='/v1/status' query='filter=' +... D - api/request-result client='cli' path='/v1/status' query='filter=' response=... +... I test01 CLIClient/check-manual-tags manual_tags='manual:test' +... I - CLIClient/check-exit exitcode=0 jobs=2 +""" + ) + == utils.log_data + ) + + +async def test_cli_check_quarantine(daemon, cli_client, log): + job = daemon.jobs["test01"] + job.backup.quarantine.add_report(QuarantineReport(b"a", b"b", 0)) + + utils.log_data = "" + try: + await cli_client.check() + except SystemExit as e: + assert e.code == 1 + assert ( + Ellipsis( + """\ +... D - api/new-conn path='/v1/status' query='filter=' +... D - api/auth-passed client='cli' path='/v1/status' query='filter=' +... D - api/request-result client='cli' path='/v1/status' query='filter=' response=... +... W test01 CLIClient/check-quarantined reports=1 +... I - CLIClient/check-exit exitcode=1 jobs=2 +""" + ) + == utils.log_data + ) diff --git a/src/backy/tests/test_daemon.py b/src/backy/tests/test_daemon.py index e6b2c3cf..a960e3e6 100644 --- a/src/backy/tests/test_daemon.py +++ b/src/backy/tests/test_daemon.py @@ -4,7 +4,6 @@ import os.path as p import re import signal -import time from unittest import mock import pytest @@ -12,7 +11,6 @@ from backy import utils from backy.backends.chunked import ChunkedFileBackend from backy.daemon import BackyDaemon -from backy.quarantine import QuarantineReport from backy.revision import Revision from backy.scheduler import Job from backy.tests import Ellipsis @@ -29,8 +27,11 @@ async def daemon(tmpdir, event_loop, log): --- global: base-dir: {base_dir} - status-interval: 1 - telnet_port: 1234 +api: + port: 1234 + tokens: + "testtoken": "cli" + "testtoken2": "cli2" schedules: default: daily: @@ -73,6 +74,10 @@ def test_reload(daemon, tmpdir): --- global: base-dir: {new_base_dir} +api: + tokens: + "newtoken": "cli" + "newtoken2": "cli2" schedules: default2: daily: @@ -93,12 +98,13 @@ def test_reload(daemon, tmpdir): ) daemon.reload() assert daemon.base_dir == new_base_dir + assert set(daemon.api_tokens) == {"newtoken", "newtoken2"} assert set(daemon.jobs) == {"test05", "foo05"} assert set(daemon.schedules) == {"default2"} - assert daemon.telnet_port == BackyDaemon.telnet_port + assert daemon.api_port == BackyDaemon.api_port -@pytest.mark.asyncio +@pytest.fixture async def test_sighup(daemon, log, monkeypatch): """test that a `SIGHUP` causes a reload without interrupting other tasks""" @@ -119,7 +125,7 @@ async def send_sighup(): assert signal_task not in all_tasks -@pytest.mark.asyncio +@pytest.fixture async def test_run_backup(daemon, log): job = daemon.jobs["test01"] @@ -222,12 +228,8 @@ def test_incomplete_revs_dont_count_for_sla(daemon, clock, tmpdir, log): assert False is job.sla -def test_status_should_default_to_basedir(daemon, tmpdir): - assert str(tmpdir / "status") == daemon.status_file - - -def test_update_status(log): - job = Job(mock.Mock(), "asdf", log) +def test_update_status(daemon, log): + job = Job(daemon, "asdf", log) assert job.status == "" job.update_status("asdf") assert job.status == "asdf" @@ -239,7 +241,7 @@ async def cancel_and_wait(job): job._task = None -@pytest.mark.asyncio +@pytest.fixture async def test_task_generator(daemon, clock, tmpdir, monkeypatch, tz_berlin): # This is really just a smoke tests, but it covers the task pool, # so hey, better than nothing. @@ -269,7 +271,7 @@ async def wait_for_job_finished(): await wait_for_job_finished() -@pytest.mark.asyncio +@pytest.fixture async def test_task_generator_backoff( daemon, clock, tmpdir, monkeypatch, tz_berlin ): @@ -357,15 +359,6 @@ async def wait_for_job_finished(): assert job.backoff == 0 -@pytest.mark.asyncio -async def test_write_status_file(daemon, event_loop): - assert p.exists(daemon.status_file) - first = os.stat(daemon.status_file) - await asyncio.sleep(1.5) - second = os.stat(daemon.status_file) - assert first.st_mtime < second.st_mtime - - def test_daemon_status(daemon): assert {"test01", "foo00"} == set([s["job"] for s in daemon.status()]) @@ -373,139 +366,3 @@ def test_daemon_status(daemon): def test_daemon_status_filter_re(daemon): r = re.compile(r"foo\d\d") assert {"foo00"} == set([s["job"] for s in daemon.status(r)]) - - -def test_check_ok(daemon, setup_structlog): - setup_structlog.default_job_name = "-" - daemon._write_status_file() - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 0 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... I - daemon/check-exit exitcode=0 jobs=2 -""" - ) - == utils.log_data - ) - - -def test_check_too_old(daemon, tmpdir, clock, log, setup_structlog): - setup_structlog.default_job_name = "-" - job = daemon.jobs["test01"] - revision = Revision(job.backup, log, "1") - revision.timestamp = utils.now() - datetime.timedelta(hours=48) - revision.stats["duration"] = 60.0 - revision.materialize() - daemon._write_status_file() - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 2 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... C test01 daemon/check-sla-violation last_time='2015-08-30 07:06:47+00:00' sla_overdue=172800.0 -... I - daemon/check-exit exitcode=2 jobs=2 -""" - ) - == utils.log_data - ) - - -def test_check_manual_tags(daemon, setup_structlog, log): - setup_structlog.default_job_name = "-" - job = daemon.jobs["test01"] - revision = Revision.create(job.backup, {"manual:test"}, log) - revision.timestamp = utils.now() - revision.stats["duration"] = 60.0 - revision.materialize() - daemon._write_status_file() - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 0 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... I test01 daemon/check-manual-tags manual_tags='manual:test' -... I - daemon/check-exit exitcode=0 jobs=2 -""" - ) - == utils.log_data - ) - - -def test_check_quarantine(daemon, setup_structlog, log): - setup_structlog.default_job_name = "-" - job = daemon.jobs["test01"] - job.backup.quarantine.add_report(QuarantineReport(b"a", b"b", 0)) - daemon._write_status_file() - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 1 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... W test01 daemon/check-quarantined reports=1 -... I - daemon/check-exit exitcode=1 jobs=2 -""" - ) - == utils.log_data - ) - - -def test_check_no_status_file(daemon, setup_structlog): - setup_structlog.default_job_name = "-" - os.unlink(daemon.status_file) - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 3 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... E - daemon/check-no-status-file status_file='...' -""" - ) - == utils.log_data - ) - - -def test_check_stale_status_file(daemon, setup_structlog): - setup_structlog.default_job_name = "-" - open(daemon.status_file, "a").close() - os.utime(daemon.status_file, (time.time() - 301, time.time() - 301)) - - utils.log_data = "" - try: - daemon.check() - except SystemExit as exit: - assert exit.code == 2 - assert ( - Ellipsis( - """\ -... I - daemon/read-config ... -... C - daemon/check-old-status-file age=... -""" - ) - == utils.log_data - ) diff --git a/src/backy/tests/test_main.py b/src/backy/tests/test_main.py index 44cd281f..8ac7b9e7 100644 --- a/src/backy/tests/test_main.py +++ b/src/backy/tests/test_main.py @@ -5,6 +5,7 @@ import pytest import backy.backup +import backy.client import backy.main from backy import utils from backy.revision import Revision @@ -28,8 +29,8 @@ def test_display_usage(capsys, argv): assert ( """\ usage: pytest [-h] [-v] [-l LOGFILE] [-b BACKUPDIR] - {backup,restore,purge,status,\ -upgrade,scheduler,check,distrust,verify,forget} + {client,backup,restore,purge,status,\ +upgrade,scheduler,distrust,verify,forget} ... """ == out @@ -47,8 +48,8 @@ def test_display_help(capsys, argv): Ellipsis( """\ usage: pytest [-h] [-v] [-l LOGFILE] [-b BACKUPDIR] - {backup,restore,purge,status,\ -upgrade,scheduler,check,distrust,verify,forget} + {client,backup,restore,purge,status,\ +upgrade,scheduler,distrust,verify,forget} ... Backup and restore for block devices. @@ -76,6 +77,10 @@ def print_args(*args, **kw): pprint.pprint(kw) +async def async_print_args(*args, **kw): + print_args(*args, **kw) + + def test_call_status(capsys, backup, argv, monkeypatch): monkeypatch.setattr(backy.main.Command, "status", print_args) argv.extend(["-v", "-b", backup.path, "status"]) @@ -156,11 +161,39 @@ def test_call_backup(tmpdir, capsys, argv, monkeypatch): assert exit.value.code == 0 -def test_call_check(capsys, backup, argv, monkeypatch, tmpdir): - monkeypatch.setattr(backy.main.Command, "check", print_args) - argv.extend( - ["-v", "-b", backup.path, "-l", str(tmpdir / "backy.log"), "check"] - ) +@pytest.mark.parametrize( + ["action", "args"], + [ + ("jobs", {"filter_re": "test"}), + ("status", dict()), + ("run", {"job": "test"}), + ("runall", dict()), + ("reload", dict()), + ("check", dict()), + ], +) +def test_call_client( + capsys, backup, argv, monkeypatch, log, tmpdir, action, args +): + monkeypatch.setattr(backy.client.CLIClient, action, async_print_args) + conf = str(tmpdir / "conf") + with open(conf, "w") as c: + c.write( + f"""\ +global: + base-dir: {str(tmpdir)} +api: + addrs: "127.0.0.1, ::1" + port: 1234 + cli-default: + token: "test" + +schedules: {{}} +jobs: {{}} +""" + ) + + argv.extend(["-v", "client", "-c", conf, action, *args.values()]) utils.log_data = "" with pytest.raises(SystemExit) as exit: backy.main.main() @@ -168,18 +201,20 @@ def test_call_check(capsys, backup, argv, monkeypatch, tmpdir): out, err = capsys.readouterr() assert ( Ellipsis( - """\ -(,) -{'config': '/etc/backy.conf'} + f"""\ +(,) +{args} """ ) == out ) assert ( Ellipsis( - """\ -... D command/invoked args='... -v -b ... check' -... D command/parsed func='check' func_args={'config': '/etc/backy.conf'} + f"""\ +... D command/invoked args='... -v client -c ... {action}{" "*bool(args)}{", ".join(args.values())}' +... D command/parsed func='client' func_args={{'config': '...', 'peer': None, \ +'url': None, 'token': None{", "*bool(args)}{str(args)[1:-1]}, 'apifunc': '{action}'}} +... I daemon/read-config ... ... D command/successful \n\ """ ) @@ -258,7 +293,7 @@ def do_raise(*args, **kw): def test_commands_wrapper_status(backup, tmpdir, capsys, clock, tz_berlin, log): commands = backy.main.Command(str(tmpdir), log) - revision = Revision(backup, log, 1) + revision = Revision(backup, log, "1") revision.timestamp = backy.utils.now() revision.materialize() @@ -268,11 +303,11 @@ def test_commands_wrapper_status(backup, tmpdir, capsys, clock, tz_berlin, log): assert err == "" assert out == Ellipsis( """\ -+----------------------+----+---------+----------+------+---------+ -| Date (...) | ID | Size | Duration | Tags | Trust | -+----------------------+----+---------+----------+------+---------+ -| ... | 1 | 0 Bytes | - | | trusted | -+----------------------+----+---------+----------+------+---------+ +┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━┳━━━━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┓ +┃ Date (...) ┃ ID ┃ Size ┃ Duration ┃ Tags ┃ Trust ┃ +┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━╇━━━━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━━┩ +│ ... │ 1 │ 0 Bytes │ - │ │ trusted │ +└──────────────────────┴────┴─────────┴──────────┴──────┴─────────┘ 1 revisions containing 0 Bytes data (estimated) """ )