Compare commits

...

261 Commits
v1.2.0 ... v6_x

Author SHA1 Message Date
Alan Orth 1912363899
Merge pull request #42 from ilri/renovate/pytest-7.x-lockfile
continuous-integration/drone/push Build is passing Details
Update dependency pytest to v7.4.4
2024-01-05 16:53:21 +03:00
Alan Orth cd3c024a77
Merge pull request #44 from ilri/renovate/flake8-7.x
Update dependency flake8 to v7
2024-01-05 16:53:03 +03:00
renovate[bot] e96c79bf2c
Update dependency flake8 to v7
continuous-integration/drone/push Build is passing Details
2024-01-05 01:26:48 +00:00
renovate[bot] d6330c7bd4
Update dependency pytest to v7.4.4
continuous-integration/drone/push Build is passing Details
2023-12-31 12:49:30 +00:00
Alan Orth 8c7a5c4047
Merge pull request #40 from ilri/renovate/isort-5.x-lockfile
continuous-integration/drone/push Build is passing Details
Update dependency isort to v5.13.2
2023-12-28 09:22:24 +03:00
Alan Orth a31c592fab
Merge pull request #41 from ilri/renovate/black-23.x-lockfile
Update dependency black to v23.12.1
2023-12-28 09:22:01 +03:00
renovate[bot] c7b179f1b5
Update dependency black to v23.12.1
continuous-integration/drone/push Build is passing Details
2023-12-23 00:02:01 +00:00
renovate[bot] 77c166c024
Update dependency isort to v5.13.2
continuous-integration/drone/push Build is passing Details
2023-12-13 23:34:34 +00:00
Alan Orth 7680b0f440
.github: update workflow
continuous-integration/drone/push Build is passing Details
- actions/checkout@v4
- actions/setup-python@v5
- python-version: '3.11'
2023-12-09 13:58:01 +03:00
Alan Orth e70a7a9675
Apply fixes from fixit
RewriteToLiteral: It's slower to call list() than using the empty literal
2023-12-09 13:57:55 +03:00
Alan Orth 24f90df13e
pyprojecy.toml: use new group syntax
poetry wants us to use `poetry add --group=dev` now, which generates
this syntax in the toml.
2023-12-09 13:57:45 +03:00
Alan Orth 780f2c1723
pyproject.toml: add fixit to dev dependencies 2023-12-09 13:57:31 +03:00
Alan Orth 53b58d4116
Merge pull request #38 from ilri/renovate/falcon-3.x
continuous-integration/drone/push Build is passing Details
Update dependency falcon to v3.1.3
2023-12-05 16:36:50 +03:00
renovate[bot] 19a6d2cea6
Update dependency falcon to v3.1.3
continuous-integration/drone/push Build is passing Details
2023-12-05 07:14:40 +00:00
Alan Orth 6c2bcda16f
Merge pull request #37 from ilri/renovate/black-23.x-lockfile
continuous-integration/drone/push Build is passing Details
Update dependency black to v23.11.0
2023-11-22 19:37:07 +03:00
renovate[bot] e4d9545b02
Update dependency black to v23.11.0
continuous-integration/drone/push Build is passing Details
2023-11-08 07:30:58 +00:00
Alan Orth 1f507d3074
Merge pull request #33 from ilri/renovate/actions-checkout-4.x
continuous-integration/drone/push Build is passing Details
Update actions/checkout action to v4
2023-10-25 12:22:47 +03:00
Alan Orth 82771d7b0c
Merge pull request #32 from ilri/renovate/psycopg2-2.x-lockfile
Update dependency psycopg2 to v2.9.9
2023-10-25 12:21:47 +03:00
Alan Orth 5ff3323f88
Merge pull request #31 from ilri/renovate/flake8-6.x-lockfile
Update dependency flake8 to v6.1.0
2023-10-25 12:20:32 +03:00
Alan Orth c7a871c2f1
Merge pull request #36 from ilri/renovate/gunicorn-21.x-lockfile
Update dependency gunicorn to v21.2.0
2023-10-25 12:20:15 +03:00
renovate[bot] b948283d40
Update dependency gunicorn to v21.2.0
continuous-integration/drone/push Build is passing Details
2023-10-25 09:12:04 +00:00
Alan Orth 124a05dcaf
Merge pull request #30 from ilri/renovate/gunicorn-21.x
continuous-integration/drone/push Build is passing Details
Update dependency gunicorn to v21
2023-10-25 12:11:21 +03:00
Alan Orth a2daf96fec
Merge pull request #29 from ilri/renovate/black-23.x-lockfile
Update dependency black to v23.10.1
2023-10-25 12:10:47 +03:00
Alan Orth 8634d53fa6
Merge pull request #28 from ilri/renovate/pytest-7.x-lockfile
Update dependency pytest to v7.4.3
2023-10-25 12:10:11 +03:00
renovate[bot] e2bfcef573
Update dependency pytest to v7.4.3
continuous-integration/drone/push Build is passing Details
2023-10-24 21:18:17 +00:00
renovate[bot] d64c4b8cbc
Update dependency black to v23.10.1
continuous-integration/drone/push Build is passing Details
2023-10-23 19:46:06 +00:00
renovate[bot] 3d91366412
Update actions/checkout action to v4
continuous-integration/drone/push Build is passing Details
2023-10-19 11:02:08 +00:00
renovate[bot] c3a4e2260b
Update dependency psycopg2 to v2.9.9
continuous-integration/drone/push Build is passing Details
2023-10-03 14:20:02 +00:00
renovate[bot] 10519997ac
Update dependency flake8 to v6.1.0
continuous-integration/drone/push Build is failing Details
2023-07-29 21:45:38 +00:00
renovate[bot] 4d7e9e9401
Update dependency gunicorn to v21
continuous-integration/drone/push Build is failing Details
2023-07-17 21:23:15 +00:00
Alan Orth fe9f98bcc0
dspace_statistics_api/util.py: format with black
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
2023-05-30 16:04:18 +03:00
Alan Orth 70f0d66c6e
poetry.lock: run poetry update 2023-05-30 16:03:51 +03:00
Alan Orth 913596c61d
pyproject.toml: bump flake8 2023-05-30 16:03:35 +03:00
Alan Orth 7cd762a5a2
Merge pull request #24 from ilri/renovate/black-23.x
continuous-integration/drone/push Build is passing Details
Update dependency black to v23
2023-05-30 11:26:12 +03:00
renovate[bot] 3811be18ef
Update dependency black to v23 2023-05-30 08:22:19 +00:00
Alan Orth a52818271c
Merge pull request #26 from ilri/renovate/pytest-7.x
Update dependency pytest to v7
2023-05-30 11:21:39 +03:00
renovate[bot] b643f60dd7
Update dependency pytest to v7 2023-05-30 08:08:39 +00:00
Alan Orth 7cec9a9545
Merge pull request #27 from ilri/renovate/postgres-15.x
Update postgres Docker tag to v15
2023-05-30 11:07:00 +03:00
renovate[bot] a9302506b6
Update postgres Docker tag to v15 2023-05-30 08:01:58 +00:00
Alan Orth b980602a03
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2023-05-30 11:00:46 +03:00
Alan Orth a4b4843036
poetry.lock: run poetry update 2023-05-30 11:00:09 +03:00
Alan Orth 7e334f6de8
Recommend Python 3.8+
I'm only using Python 3.8+ anyways, and 3.7 is end of life as of
next month. Some deps start wanting Python 3.8 so let's just bump
it.
2023-05-30 10:23:10 +03:00
Alan Orth 770f676fb5
Use PostgreSQL 14 for Drone CI and GitHub Actions 2023-05-30 10:14:47 +03:00
Alan Orth 6d5e3c350d
Remove .hound.yml
I haven't used this in years and now we have automatic tests via
GitHub Actions.
2023-05-30 10:14:06 +03:00
Alan Orth 531136183b
pyproject.toml: rework isort and black deps
We no longer need to gatekeep these for Python 3.6+, as all my dev
systems are running Python 3.11 and all my production systems are
running Python 3.8+.
2023-05-30 10:09:47 +03:00
Alan Orth 1a3d0350a5
.github/workflows: update GitHub Action workflow
We can use Poetry directly rather than installing deps with pip.
2023-05-30 09:39:00 +03:00
Alan Orth 25c4f05f16
Add renovate.json
Disable management of requirements.txt since I am using poetry.
2023-05-30 08:11:28 +03:00
Alan Orth 9fba8d1b81
Migrate isort config to pyproject.toml
continuous-integration/drone/push Build is failing Details
See: https://pycqa.github.io/isort/docs/configuration/black_compatibility.html
2022-12-20 15:13:44 +02:00
Alan Orth 568ced0f20
poetry.lock: run poetry update 2022-12-20 15:12:50 +02:00
Alan Orth 9cd93c9034 Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --with dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2022-11-21 10:51:43 +03:00
Alan Orth 83a2625987 CHANGELOG.md: add note about falcon 3.1.1 2022-11-21 10:51:43 +03:00
Alan Orth f591ed7162 Use falcon v3.1.1
Bug fix release with support for Python 3.11.

See: https://falcon.readthedocs.io/en/3.1.1/changes/3.1.1.html
2022-11-21 10:51:43 +03:00
Alan Orth bb0f267941
.github: use ubuntu-22.04 for actions
continuous-integration/drone/push Build is passing Details
Apparently 'ubuntu-latest' is still 20.04 and today is 2022-10-03,
which seems a bit old!

See: https://github.com/actions/runner-images
2022-10-03 19:57:56 +03:00
Alan Orth 0720605b6a
.github: update actions
Switch to newer checkout and setup-python actions, and enable the
pip cache for setup-python.
2022-10-03 19:43:41 +03:00
Alan Orth bcb97d025c
CHANGELOG.md: add note about PostgreSQL 12
continuous-integration/drone/push Build is passing Details
2022-05-31 13:05:41 +03:00
Alan Orth 0ff8490275
Use PostgreSQL 12 in CI
I migrated my production systems to PostgreSQL 12 months ago.
2022-05-31 13:05:01 +03:00
Alan Orth 0a8ac60ade
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2022-03-28 16:01:39 +03:00
Alan Orth 37527c21be
poetry.lock: run poetry update 2022-03-28 16:01:13 +03:00
Alan Orth eb660f8085
CHANGELOG.md: add note about Python version
continuous-integration/drone/push Build is passing Details
2022-03-28 15:16:02 +03:00
Alan Orth e7d780f511
Don't test on Python 3.6 anymore
Python 3.6 is deprecated in Falcon 3.1.0 and all of our production
and development hosts are running Python 3.8+ now.
2022-03-28 15:13:54 +03:00
Alan Orth c3b9a541b7
Bump version to 1.4.4-dev
continuous-integration/drone/push Build is passing Details
2022-03-26 19:10:47 +03:00
Alan Orth 1a1a14a25f
Version 1.4.3 2022-03-26 19:08:56 +03:00
Alan Orth c09fc789e8
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2022-03-26 19:06:54 +03:00
Alan Orth 134a4f1595
poetry.lock: run poetry update 2022-03-26 18:50:31 +03:00
Alan Orth 12ebd1aed5
pyproject.toml: falcon 3.1.0
Doesn't seem to have any breaking changes, only fixes and some new
compatability updates with new Pythons.

See: https://falcon.readthedocs.io/en/stable/changes/3.1.0.html
2022-03-26 18:49:33 +03:00
Alan Orth e5f3201b65
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2022-03-21 15:19:00 +03:00
Alan Orth c1ce4fe233
poetry.lock: run poetry update 2022-03-21 15:18:27 +03:00
Alan Orth b2eb1878a5
.github/workflows/python-app.yml: quote python version
continuous-integration/drone/push Build is passing Details
2022-01-30 18:57:10 +03:00
Alan Orth a0213c1c97
poetry.lock: run poetry update
continuous-integration/drone/push Build is passing Details
2022-01-30 13:52:20 +03:00
Alan Orth cd03ca2b36
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2022-01-30 13:50:49 +03:00
Alan Orth c48e6a79c7
pyproject.toml: update dependencies
We no longer need ipython because it's installed globally on all
my machines. Also, new major version of flake8 and black is no
longer a beta.
2022-01-30 13:49:36 +03:00
Alan Orth a2e1695ecc
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-12-19 14:18:42 +02:00
Alan Orth b683bf211c
.github/workflows/python-app.yml: use Python 3.10 2021-12-19 14:15:41 +02:00
Alan Orth 3ab48743d6
poetry.lock: run poetry update 2021-12-19 14:13:14 +02:00
Alan Orth 88173eaae9
README.md: fix link to actions
continuous-integration/drone/push Build is passing Details
2021-12-08 11:34:50 +02:00
Alan Orth f557d33f36
README.md: adjust intro
Use intro style from Python Black! This makes it easier to have the
badges displayed without wrapping and looks nicer.
2021-12-08 09:48:31 +02:00
Alan Orth ffc4ff4a5c
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-11-11 09:05:37 +02:00
Alan Orth 7551b34632
poetry.lock: run poetry update
This was previously failing for the past few days.
2021-11-11 09:05:03 +02:00
Alan Orth 5e71ec10eb
Remove pipenv
Poetry's working again.
2021-11-11 09:04:30 +02:00
Alan Orth f80d360cf9
Only install ipython on Python 3.7+
continuous-integration/drone/push Build is passing Details
2021-11-10 09:21:59 +02:00
Alan Orth e70b59ecfe
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with pipenv lock:

    $ pipenv lock -r > requirements.txt
    $ pipenv lock -r --dev > requirements-dev.txt
2021-11-09 22:52:21 +02:00
Alan Orth 4d0828b6c0
Add Pipenv configuration
I was having a problem with Poetry.
2021-11-09 22:51:23 +02:00
Alan Orth dabc4c0259
pyproject.toml: revert to my fork of falcon-swagger-ui
The Falcon 3 fix never actually got committed to rdidyik's fork. I
have submitted a new pull request and will use my fork until it is
merged.

See: https://github.com/rdidyk/falcon-swagger-ui/pull/21
2021-11-09 22:09:11 +02:00
Alan Orth 4fd8af07c3
.drone.yml: Fix job name 2021-11-09 17:36:48 +02:00
Alan Orth 4c5326a176
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-10-21 15:07:26 +03:00
Alan Orth 3b1ccafab4
poetry.lock: Run poetry update 2021-10-21 15:06:19 +03:00
Alan Orth 58b5ae82d3
pyproject.toml: Switch back to falcon-swagger-ui upstream
They merged my changes for Falcon 3.0.

See: https://github.com/rdidyk/falcon-swagger-ui/pull/20
2021-10-21 15:04:58 +03:00
Alan Orth 562aaeef7d
.drone.yml: Test on Python 3.10
continuous-integration/drone/push Build is failing Details
2021-10-11 20:11:32 +03:00
Alan Orth 5cdba6acb1
.drone.yml: Also install gcc for all Python containers
continuous-integration/drone/push Build is passing Details
We previously only needed gcc for typed-ast in Python 3.9, but now
we actually need gcc to compile psycopg2 in all of them.
2021-07-06 16:44:13 +03:00
Alan Orth dd0937179c
.drone.yml: Add libpq-dev to test container
continuous-integration/drone/push Build was killed Details
We need it to compile the psycopg2 Python library.
2021-07-06 16:41:17 +03:00
Alan Orth f0c6c004db
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-07-06 16:27:23 +03:00
Alan Orth 6843f0a8ac
poetry.lock: Run poetry update 2021-07-06 16:26:33 +03:00
Alan Orth f5fcfcc05a
pyproject.toml: Update psycopg2 version
I manually re-installed psycopg2@latest while troubleshooting an
issue with it not working after Arch Linux updated Python. That's
one down side of using the non-binary package.
2021-07-06 16:26:05 +03:00
Alan Orth e8ac74b6d1
pyproject.toml: Update some dev dependencies 2021-07-06 16:17:22 +03:00
Alan Orth 14fc14daee
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-06-22 20:53:07 +03:00
Alan Orth 871aae537a
poetry.lock: Sync changes 2021-06-22 20:52:15 +03:00
Alan Orth 2fada6c6ff
pyproject.toml: Use psycopg2 instead of psycopg2-binary
continuous-integration/drone/push Build is passing Details
According to the documentation the binary version is not meant to
be run in production. Since I'm in control of both my development
and production servers and can ensure that libpq-dev is installed
on both, I will use the source version of this module.

See: https://www.psycopg.org/docs/install.html#quick-install
2021-06-22 17:49:49 +03:00
Alan Orth ef0991e352
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-06-22 10:11:57 +03:00
Alan Orth 4502d6053c
poetry.lock: run poetry update
The following packages were updated:

> markupsafe (2.0.0 -> 2.0.1)
> certifi (2020.12.5 -> 2021.5.30)
> click (8.0.0 -> 8.0.1)
> decorator (5.0.7 -> 5.0.9)
> jinja2 (3.0.0 -> 3.0.1)
> prompt-toolkit (3.0.18 -> 3.0.19)
> urllib3 (1.26.4 -> 1.26.5)
> ipython (7.23.1 -> 7.24.1)
> psycopg2-binary (2.8.6 -> 2.9.1)
2021-06-22 10:10:29 +03:00
Alan Orth a524068cf6
Bump version to 1.4.3-dev
continuous-integration/drone/push Build is passing Details
2021-04-15 14:44:44 +03:00
Alan Orth 964d5dff06
Version 1.4.2 2021-04-15 14:23:07 +03:00
Alan Orth a9252d1771
Update requirements-dev.txt
Generated with poetry export:

    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-04-15 14:19:48 +03:00
Alan Orth a63687d516
poetry.lock: Run poetry update 2021-04-15 14:17:17 +03:00
Alan Orth 73dc3a292e
README.md: Remove TODO about Swagger
continuous-integration/drone/push Build is passing Details
I added the SwaggerUI interface a few months ago.
2021-04-06 20:28:10 +03:00
Alan Orth 1e742bad41
CHANGELOG.md: Add note about valid page tests
continuous-integration/drone/push Build is passing Details
2021-04-06 09:07:51 +03:00
Alan Orth 164008981e
CHANGELOG.md: Add notes about Falcon 3.0.0
continuous-integration/drone/push Build is passing Details
2021-04-06 08:58:00 +03:00
Alan Orth dd1769b954
tests: Fix totalPages
A few months ago I fixed the totalPages display to show 1 when we
only have one page of results (the page itself is still 0), but I
didn't update the tests.

See: 4f8cd1097b
2021-04-06 08:54:54 +03:00
Alan Orth b009820fb4
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-04-06 08:32:22 +03:00
Alan Orth 9830295978
poetry.lock: Run poetry update 2021-04-06 08:31:50 +03:00
Alan Orth c93a4d7455
pyproject.toml: Falcon 3.0.0
Release notes: https://falcon.readthedocs.io/en/latest/changes/3.0.0.html
2021-04-06 08:31:39 +03:00
Alan Orth 2f8e4f8a0a Changes for Falcon 3.0.0
Mostly it seems we just need to use resp.text instead of resp.body,
including in falcon-swagger-ui (I forked the upstream one to make
this change).

See: https://falcon.readthedocs.io/en/latest/changes/3.0.0.html
2021-04-06 08:30:28 +03:00
Alan Orth 0650c5985e
Add SPDX short license identifier to all Python files
continuous-integration/drone/push Build is passing Details
See: https://spdx.github.io/spdx-spec/appendix-V-using-SPDX-short-identifiers-in-source-files/
2021-03-22 13:42:42 +02:00
Alan Orth d814f1c4f0
CHANGELOG.md: Fix heading
continuous-integration/drone/push Build is passing Details
2021-03-21 19:50:39 +02:00
Alan Orth 00f30591c4
CHANGELOG.md: Add notes about GitHub Actions 2021-03-21 19:49:35 +02:00
Alan Orth acfe87b91a
Add GitHub Actions badge and remove sr.ht
continuous-integration/drone/push Build is passing Details
2021-03-21 11:48:05 +02:00
Alan Orth bc6d84dda2 Add GitHub Actions workflow
My first time setting up a PostgreSQL service container on GitHub
actions. Note that there are two different kinds of environment
variables: those passed to the Docker container, and those used by
the PostgreSQL utilities.

See: https://docs.github.com/en/actions/guides/creating-postgresql-service-containers
See: https://hub.docker.com/_/postgres
2021-03-21 11:44:39 +02:00
Alan Orth 889fb2f74a
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-03-21 08:59:41 +02:00
Alan Orth c42cd7a818
poetry.lock: Run poetry update 2021-03-21 08:59:04 +02:00
Alan Orth f8bba59d66
.gitignore: Ignore .egg-info
continuous-integration/drone/push Build is passing Details
2021-03-14 21:50:47 +02:00
Alan Orth b8cb752a29
CHANGELOG.md: Add note about updated poetry deps
continuous-integration/drone/push Build is passing Details
2021-03-11 11:23:18 +02:00
Alan Orth 09496aa2b5
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-03-11 11:22:05 +02:00
Alan Orth ff5dc7506d
poetry.lock: Run poetry update 2021-03-11 11:21:02 +02:00
Alan Orth 80a11ead97
Version 1.4.1
continuous-integration/drone/push Build is passing Details
2021-01-14 14:19:50 +02:00
Alan Orth a282c95933
CHANGELOG.md: Minor syntax fix 2021-01-14 14:15:57 +02:00
Alan Orth fd7cc36306
Update requirements-dev.txt
Generated with poetry export:

    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2021-01-14 14:14:16 +02:00
Alan Orth a20ff09570
poetry.lock: Run poetry update
All tests still pass.
2021-01-14 14:13:32 +02:00
Alan Orth fdc0e73088
tests: Sort imports with isort 2021-01-14 14:12:59 +02:00
Alan Orth b15afc9f39
CHANGELOG.md: Add note about UUIDs
continuous-integration/drone/push Build is passing Details
2021-01-05 12:41:21 +02:00
Alan Orth 2bc18ef719
README.md: Make a note about migrating UUIDs 2021-01-05 12:35:23 +02:00
Alan Orth 49751b53f0
dspace_statistics_api/indexer.py: Limit to UUIDs
We need to make sure that the indexer only tries to index UUIDs, as
opposed to legacy IDs that may have been left over from a migration
from earlier DSpace versions. For example, "98110-unmigrated", "-1"
etc.

For matching the UUIDs in Solr I decided that it is sufficient for
our use case to simply match thirty-six characters, where a UUID is
composed of thirty-two hexadecimal characters and four dashes. We
don't need to do any verification of "real" UUIDs because it would
be needlessly complex in our case.

See: https://github.com/ilri/dspace-statistics-api/issues/12
2021-01-05 12:30:27 +02:00
Alan Orth d1c177e146
.drone.yml: Add git to python container
continuous-integration/drone/push Build is passing Details
Now that I am installing my own fork of falcon-swagger-ui we need
to have git so we can install it with pip.
2020-12-27 14:22:23 +02:00
Alan Orth 33dc210452
dspace_statistics_api/docs/openapi.json: Minor edit
Better to leave the version in there because Swagger Editor doesn't
like it without. Also, change the example page parameter for POSTing
to /items and /collections, as it doesn't make sense to start on a
later page if we have less items than our limit.
2020-12-27 13:53:59 +02:00
Alan Orth 282d5f644a
Move unreleased change to v1.4.0 2020-12-27 12:52:24 +02:00
Alan Orth 05e0e8bdca
openapi.json: Set the API version from config
We don't need to hard code this in the JSON anymore since we are
reading and modifying it now for the server config anyways.
2020-12-27 12:48:13 +02:00
Alan Orth 2567bb8604
dspace_statistics_api/app.py: Format with black 2020-12-27 12:27:01 +02:00
Alan Orth 4af3c656a3
CHANGELOG.md: Add note about totalPages 2020-12-27 12:26:32 +02:00
Alan Orth 4f8cd1097b
Rework paging
The "totalPages" value in our response is calculated incorrectly.
Instead of casting to int and rounding, we should rather round up
to the next integer with math.ceil. This is a more correct way to
get the value.

Also update the indexer to use the same logic, although there the
values are printed with +1 so they are more readable.
2020-12-27 12:22:07 +02:00
Alan Orth a02211fd60
Update requirements
continuous-integration/drone/push Build is failing Details
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2020-12-25 13:03:32 +02:00
Alan Orth fc814593c7
Use my fork of falcon-swagger-ui
It has a newer Swagger UI (v3.38.0).
2020-12-25 12:57:58 +02:00
Alan Orth 7de1084f60
Add whitespace before vim modeline
continuous-integration/drone/push Build is passing Details
black wants this...
2020-12-24 13:12:06 +02:00
Alan Orth 6b78e82fe9
Add vim modeline to all tests 2020-12-24 13:11:12 +02:00
Alan Orth 4004515967
pyproject.toml: Update description
continuous-integration/drone/push Build is passing Details
2020-12-23 16:15:46 +02:00
Alan Orth d1229c2387
Adjust docs at root
Don't use a static HTML file anymore. Now I simply print an XHTML
page from the Falcon resource. This way I can use variables to add
in the API version as well as a link to the Swagger UI.

The list of API calls is still present on the README.md, though in
the long run I might move them to some dedicated documentation or
a GitHub wiki.
2020-12-23 16:12:50 +02:00
Alan Orth be83514de1
Re-work Swagger UI configuration
continuous-integration/drone/push Build is passing Details
It turns out that Swagger UI mostly does the "right" thing for our
use cases here, but it assumes that API paths are relative to the
root of the host where it is being served. This works in the local
development environment because we are serving on "/", but it does
not work in production where the API is deployed beneath the DSpace
REST API, for example at "/rest/statistics".

The solution here is to allow configuration of the DSpace Statistics
API path and use that when registering the Swagger UI as well as in
a new "server" block in the OpenAPI JSON schema.

By default it is configured to work out of the box in a development
environment. Set the DSPACE_STATISTICS_API_URL environment variable
to something like "/rest/statistics" when running in production.
2020-12-23 13:25:17 +02:00
Alan Orth 70b2ba83ba
Allow configuration of Swagger and OpenAPI JSON URL
continuous-integration/drone/push Build is passing Details
When running in production your statistics API might be deployed to
a path like /rest/statistics instead of at the root.
2020-12-22 12:50:03 +02:00
Alan Orth 893039bc6a
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2020-12-22 12:07:07 +02:00
Alan Orth a4628dde4e
Update requirements
Generated with poetry export:

    $ poetry export -f requirements.txt > requirements.txt
    $ poetry export --dev -f requirements.txt > requirements-dev.txt
2020-12-22 11:45:39 +02:00
Alan Orth 68418ea053
dspace_statistics_api/docs/openapi.json: Add /status
Add a /status to the Swagger UI schema.
2020-12-22 11:41:47 +02:00
Alan Orth 6bbee7919e
Bump version to 1.4.0-dev 2020-12-22 11:31:46 +02:00
Alan Orth 8f0061ce29
CHANGELOG.md: Add note about the /status page 2020-12-22 11:30:50 +02:00
Alan Orth 4b1398c67f
Add /status route
Currently this only prints the API version.
2020-12-22 11:30:09 +02:00
Alan Orth a9d2a6d9be
CHANGELOG.md: Add note about Swagger UI 2020-12-22 11:21:46 +02:00
Alan Orth a35ecf2394
Add Swagger UI on /swagger
This includes a Swagger UI with an OpenAPI 3.0 JSON schema for easy
interactive demonstration and testing of the API. The JSON schema
was created with the standalone swagger-editor. Includes tests to
make sure that the /swagger and /docs/openapi.json paths are acce-
ssible.
2020-12-22 11:18:47 +02:00
Alan Orth 3e271c7852
tests/dspacestatistics.sql: Update data
continuous-integration/drone/push Build is passing Details
Add a new database snapshot with communities and collections.
2020-12-20 22:31:41 +02:00
Alan Orth d7ba14c590
tests: Add tests for communities and collections
Also, separate tests for items, communities, and collections into
their own files, leaving a single test for docs in its own file.
2020-12-20 22:12:13 +02:00
Alan Orth ab82e90773
dspace_statistics_api/stats.py: Use -isBot:true
continuous-integration/drone/push Build is passing Details
Minor change to bot filtering. We should use a negated match for
documents that have `isBot:true` rather than looking for documents
that are tagged with `isBot:false` (the distinction is subtle, but
important).
2020-12-20 16:56:03 +02:00
Alan Orth 8a1244d2d0
Update changelog and docs 2020-12-20 16:45:49 +02:00
Alan Orth 04f0756c7f
dspace_statistics_api/util.py: Add vim modeline 2020-12-20 16:31:52 +02:00
Alan Orth 830e4415f5
dspace_statistics_api/app.py: Run isort 2020-12-20 16:29:35 +02:00
Alan Orth 47b4eb3df7
Rename items.py to stats.py
It is no longer used only for item-related statistics functions.
2020-12-20 16:28:56 +02:00
Alan Orth 3339bf8d9c
Add communities and collections support to API
The basic logic is similar to items, where you can request single
item statistics with a UUID, all item statistics, and item statis-
tics for a list of items (optionally with a date range). Most of
the item code was re-purposed to work on "elements", which can be
items, communities, or collections depending on the request, with
the use of Falcon's `before` hooks to set the statistics scope so
we know how to behave for the current request.

Other than the minor difference in facet fields, another issue I
had with communities and collections is that the owningComm and
owningColl fields are multi-valued (unlike items' id field). This
means that, when you facet the results of your query, Solr returns
ids that seem unrelated, but are actually present in the field, so
I had to make sure I checked all returned ids to see if they were
in the user's POSTed elements list.

TODO:
  - Add tests
  - Revise docstrings
  - Refactor items.py as it is now generic
2020-12-20 16:14:46 +02:00
Alan Orth fba6f1ead1 CHANGELOG.md: Update unreleased changes
continuous-integration/drone/push Build is passing Details
2020-12-18 22:54:01 +02:00
Alan Orth 20c8ba0cf8 indexer.py: Add support for communities and collections
The logic to get views and downloads is very similar to that used
for items, but we facet by different fields. This uses a generic
function for indexing that takes an "indexType" and a "facetField"
parameter. The indexType parameter controls which database table
to insert into, and the facetField parameter indicates which field
to facet by in Solr.
2020-12-18 22:53:16 +02:00
Alan Orth b486f51dd7 indexer.py: Rename index functions for items
Start making plans for indexing communities and collections.
2020-12-18 22:53:16 +02:00
Alan Orth 787eec20ea
CHANGELOG.md: Add note about imports
continuous-integration/drone/push Build is passing Details
2020-12-18 22:52:14 +02:00
Alan Orth 9e6fcf279b
dspace_statistics_api/items.py: Format with black 2020-12-18 22:45:39 +02:00
Alan Orth 4dbf734a4b
Move all imports to top of file
A few months ago I had an issue setting up mocking because I was
trying to be clever importing these libraries only when I needed
them rather than at the global scope. Someone pointed out to me
that if the imports are at the top of the file Falcon will load
them once when the WSGI server starts, whereas if they are in the
on_get() or on_post() they will load for every request! Also, it
seems that PEP8 recommends keeping imports at the top of the file
anyways, so I will just do that.

Imports sorted with isort.

See: https://www.python.org/dev/peps/pep-0008/#imports
2020-12-18 22:42:06 +02:00
Alan Orth a0d0a47150
items.py: Add fl paramter to Solr queries
I forgot to add the fl parameter here as well.
2020-12-18 16:12:34 +02:00
Alan Orth 01e9756cf2
Update requirements
continuous-integration/drone/push Build is passing Details
Generated with poetry export:

    $ poetry export -f requirements.txt > requirements.txt
    $ poetry export --dev -f requirements.txt > requirements-dev.txt
2020-12-18 11:20:17 +02:00
Alan Orth b2b4eb2939
poetry.lock: Run poetry update 2020-12-18 11:19:16 +02:00
Alan Orth 4bbbaa4af3
dspace_statistics_api/indexer.py: Use `fl` parameter
continuous-integration/drone/push Build is passing Details
I forgot to add the fl parameter to the downloads function.
2020-12-18 10:44:02 +02:00
Alan Orth 7e4d5f4b13
README.md: Minor edit to intro 2020-12-18 10:42:48 +02:00
Alan Orth 428172854d
README.md: Add TODO
continuous-integration/drone/push Build is passing Details
2020-12-17 20:44:25 +02:00
Alan Orth 2707cb37d5
CHANGELOG.md: Add note about fl parameter
continuous-integration/drone/push Build is failing Details
2020-12-17 12:27:11 +02:00
Alan Orth 2407aeec70
dspace_statistics_api/indexer.py: Use `fl` parameter
When indexing item views and downloads the only field we need is the
the id. The `fl` parameter tells Solr which fields to return in the
search results. This should theoretically be more efficient, though
I don't have any time to figure out how to measure it right now.
2020-12-17 12:25:28 +02:00
Alan Orth f3a0e3a671
CHANGELOG.md: Add note about ORDER BY
continuous-integration/drone/push Build is passing Details
2020-12-17 10:17:23 +02:00
Alan Orth 4590fc8708
dspace_statistics_api/app.py: Use ORDER BY in /items
Since we are paging through the results by limit/offset we need to
be sure that we are returning results deterministically.
2020-12-17 10:10:40 +02:00
Alan Orth 8b924cf450
Remove TravisCI config
continuous-integration/drone/push Build is passing Details
I will use other CIs since TravisCI changed their business model.
2020-12-15 09:38:51 +02:00
Alan Orth ea24c73a6a
.drone.yml: Install gcc for Python 3.9
It appears to be needed to compile typed-ast:

    gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -fPIC -Iast27/Include -I/usr/local/include/python3.9 -c ast27/Custom/typed_ast.c -o build/temp.linux-x86_64-3.9/ast27/Custom/typed_ast.o
    error: command 'gcc' failed: No such file or directory
    ----------------------------------------
    ERROR: Failed building wheel for typed-ast
2020-12-14 22:50:21 +02:00
Alan Orth cd98d33615
.drone.yml: Only install requirements-dev.txt
It seems that Poetry's --dev export includes both dev and non-dev
libraries so we don't need to install both.
2020-12-14 22:42:00 +02:00
Alan Orth 9d112266ca
Update requirements-dev.txt
Generated with poetry export:

    $ poetry export --dev -f requirements.txt > requirements-dev.txt
2020-12-14 22:05:18 +02:00
Alan Orth 2b067050ff
Remove pytest-clarity
It is missing a six dependency which causes the build to fail. I
could simply add six to the virtualenv but it feels dirty. I don't
actually *need* pytest-clarity for anything so I'll just remove it.

See: https://github.com/darrenburns/pytest-clarity/issues/14
2020-12-14 22:05:03 +02:00
Alan Orth dc683f2d1c
pytest.ini: Change --strict to --strict-markers
This is deprecated since pytest 6.2.0.

See: https://docs.pytest.org/en/stable/deprecations.html#the-strict-command-line-option
2020-12-14 19:07:02 +02:00
Alan Orth f60f529bd7
Update requirements
Generated with poetry export:

    $ poetry export -f requirements.txt > requirements.txt
    $ poetry export --dev -f requirements.txt > requirements-dev.txt
2020-12-14 15:41:40 +02:00
Alan Orth 7db8458201
poetry.lock: Run poetry update
[SKIP CI]
2020-12-14 15:40:06 +02:00
Alan Orth 707f878b94 Add .drone.yml
Uses multiple pipelines to test several versions of Python. A few
things to note:

- I use the -slim Python packages, which are smaller and yet still
have no problem installing psycopg2-binary with pip
- I have to start a PostgreSQL database service for each pipeline
separately
2020-12-14 15:38:50 +02:00
Alan Orth 930250352a
Update docs about POST /items 2020-12-13 20:09:20 +02:00
Alan Orth e27f30ba4d
README.md: Use travis-ci.com domain for badge link 2020-12-08 09:11:38 +02:00
Alan Orth 28d1917038
README.md: Use travis-ci.com domain for badge 2020-12-08 09:09:19 +02:00
Alan Orth fc6a9c2ad1
tests: Update for real data
Now that CGSpace is running DSpace 6 I will use some real UUIDs to
make things easier in the future.
2020-11-25 14:56:47 +02:00
Alan Orth 3125e96a16
Bump version to 1.3.2 2020-11-18 22:01:18 +02:00
Alan Orth 66143ff00f
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2020-11-18 21:59:33 +02:00
Alan Orth 2d15f12be9
poetry.lock: Run poetry update 2020-11-18 21:58:32 +02:00
Alan Orth 9218039e61
CHANGELOG.md: Add note about limit param 2020-11-18 21:57:12 +02:00
Alan Orth 88a8db6c78
Make sure limit is between 1 and 100
We were not properly checking whether the limit was actually less
than or equal to 100.
2020-11-18 21:55:54 +02:00
Alan Orth 3995eba0a7
CHANGELOG.md: Add note about Solr bot filter 2020-11-17 17:42:30 +02:00
Alan Orth 810508d038
dspace_statistics_api/indexer.py: Use -isBot:true
Minor change to bot filtering. We should use a negated match for
documents that have `isBot:true` rather than looking for documents
that are tagged with `isBot:false` (the distinction is subtle, but
important).
2020-11-17 17:40:08 +02:00
Alan Orth ecafab57cb
README.md: Update DSpace version note 2020-11-16 16:16:21 +02:00
Alan Orth 9c9431b58c
CHANGELOG.md: Add unreleased changes 2020-11-02 22:14:18 +02:00
Alan Orth 2d6520fc97
Fix limit in docs 2020-11-02 22:14:08 +02:00
Alan Orth 79a393d33f
Update requirements
Generated with poetry export:

    $ poetry export --without-hashes -f requirements.txt > requirements.txt
    $ poetry export --without-hashes --dev -f requirements.txt > requirements-dev.txt

The `--without-hashes` is required to work around an issue with
gunicorn pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2020-11-02 22:10:29 +02:00
Alan Orth 149f6c418f
poetry.lock: Run poetry update 2020-11-02 22:00:29 +02:00
Alan Orth ca1582a8b6
Make sure limit is between 1 and 100
We were not properly checking whether the limit was greater than 0
in all cases.
2020-11-02 21:59:20 +02:00
Alan Orth 1904c243a4
Revert ".travis.yml: Use Ubuntu 20.04 "Focal" environment"
This reverts commit 0baa07f70a.

Focal only has PostgreSQL 12 installed, and we are not quite there
yet (our production has 9.6, testing has 10).
2020-10-29 00:11:28 +03:00
Alan Orth 0baa07f70a
.travis.yml: Use Ubuntu 20.04 "Focal" environment 2020-10-29 00:04:47 +03:00
Alan Orth 59214ffcb6
.travis.yml: Bump Python versions
Test Python 3.9 now that it was released, and allow tests to fail
on nightly builds.
2020-10-29 00:03:58 +03:00
Alan Orth 549b8bf1a7
dspace_statistics_api/docs/index.html: Fix version
We need to print it in the body, not the title.
2020-10-06 22:22:11 +03:00
Alan Orth 899a79b2e7
Version 1.3.1 2020-10-06 22:15:52 +03:00
Alan Orth 4c59469055
Update requirements
Generated with poetry export:

    $ poetry export --no-hashes -f requirements.txt > requirements.txt
    $ poetry export --no-hashes --dev -f requirements.txt > requirements-dev.txt

The `--no-hashes` is required to work around an issue with gunicorn
pulling in a dependency on setuptools that poetry ignores.

See: https://github.com/python-poetry/poetry/issues/1584
2020-10-06 22:11:54 +03:00
Alan Orth 4e9064329d
Bump version to 1.3.0 2020-10-06 21:33:38 +03:00
Alan Orth 4958d5d2e9
pyproject.toml: Fix email 2020-10-06 18:46:31 +03:00
Alan Orth 923ed0a434
tests/test_api.py: Add tests for /items POST handlers
This adds tests for the new /items POST handler, both with mocked
data and a live connection to a Solr statistics core. Tests that
only work when Solr is available are marked with XFAIL so that they
don't turn the whole test suite red.

In each test I try to assert as many parameters as we can know for
each response so that we cover all expectations. For example, when
we test a valid limit parameter we should test whether the response
not only has the same limit parameter, but that the number of items
has actually been limited and the number of pages has been adjusted
accordingly.

See: https://docs.pytest.org/en/stable/skipping.html
2020-10-06 16:51:53 +03:00
Alan Orth 5acd927210
dspace_statistics_api: Sort imports with isort 2020-10-06 15:12:13 +03:00
Alan Orth 630fa0d5fb
dspace_statistics_api/util.py: Fix f-strings
flake8 raised this warning:

    F541 f-string is missing placeholders
2020-10-06 15:11:12 +03:00
Alan Orth 58d2b8d4ed
dspace_statistics_api/items.py: Move util import
Move util import from global scope because it causes tests to fail.
We don't need the set up the Solr connection unless we're actually
trying to use the get_views and get_downloads methods, either when
running the API in production or during tests where the connection
has been set up.
2020-10-06 15:07:00 +03:00
Alan Orth e6572d9469
.build.yml: Use poetry instead of pipenv 2020-10-05 22:37:42 +03:00
Alan Orth 85fca81611
Update requirements
Generated with poetry export:

    $ poetry export -f requirements.txt > requirements.txt
    $ poetry export --dev -f requirements.txt > requirements-dev.txt
2020-10-05 22:33:27 +03:00
Alan Orth c81a8d03d7
Remove pipenv configuration
I was having issues with re-creating an environment from scratch:

    ModuleNotFoundError: No module named 'virtualenv.seed.via_app_data'

Switching to Poetry for now.
2020-10-05 22:32:01 +03:00
Alan Orth 2923a3b325
Add Poetry configuration
I was having some problems with pipenv when trying to install a
clean environment:

    ModuleNotFoundError: No module named 'virtualenv.seed.via_app_data'
2020-10-05 22:30:35 +03:00
Alan Orth d4518d62ad
dspace_statistics_api/app.py: Refactor for testability
I thought it was clever to only import these in the on_post handler
because they aren't needed elsewhere, but it turns out that this is
not a common pattern and even causes problems with testability.

First, if the imports are at the top of the file as PEP8 recommends,
then the WSGI server will import them once when it loads the app and
they remain in memory for the lifecycle of the app. If the imports
are in the on_post handler they would be re-imported on every request!

Second, this pattern of importing in a method makes it tricky to use
object patching in mocks.

See: https://www.python.org/dev/peps/pep-0008/#imports
2020-10-05 20:43:50 +03:00
Alan Orth 3a98de78e3
dspace_statistics_api/items.py: Remove executable bit
We don't need to execute this on the command line.
2020-10-05 14:33:36 +03:00
Alan Orth b26439daf3
CHANGELOG.md: Add note about Python dependencies 2020-09-27 11:16:37 +03:00
Alan Orth 9e898ba54f
Update requirements
Generated from pipenv with:

  $ pipenv lock -r > requirements.txt
  $ pipenv lock -r -d > requirements-dev.txt
2020-09-27 11:13:38 +03:00
Alan Orth 716d65030c
Pipfile.lock: Run pipenv update 2020-09-27 11:13:04 +03:00
Alan Orth 5a53b57b3b
Refactor `/items` POST handler to use a before hook
This allows us to do the dirty work of parsing, validating, and
setting local variables from the POST parameters outside of the
on_post function. We then share the parameters via the req.context
object. Functionally it is the same, but readability is better
and it's a neat trick that I could use elsewhere.

See: https://falcon.readthedocs.io/en/stable/user/faq.html#how-can-i-pass-data-from-a-hook-to-a-responder-and-between-hooks
2020-09-26 18:40:52 +03:00
Alan Orth 3ceb9a6eb0
dspace_statistics_api/items.py: Fix flake8 warning
According to flake8 we need to use a different syntax for strings
with backslash escape sequences:

> As of Python 3.6, a backslash-character pair that is not a valid
> escape sequence now generates a DeprecationWarning. This will
> eventually become a SyntaxError.

The warning was:

    W605 invalid escape sequence '\-'

See: https://www.flake8rules.com/rules/W605.html
2020-09-26 12:22:06 +03:00
Alan Orth 946f0749e2
dspace_statistics_api/app.py: Use bounded_stream in on_post
For reasons I don't quite understand, we need to use bounded_stream
in the on_post request handler in order to use simulate_post() with
the testing client in Falcon 2.0.0. Normal runtime operation via
gunicorn does not have any issues with stream.

See: https://github.com/falconry/falcon/issues/1720
See: https://github.com/falconry/falcon/issues/1554
2020-09-26 11:50:57 +03:00
Alan Orth b06651d1ec
dspace_statistics_api/indexer.py: Fix Python comment 2020-09-25 13:35:05 +03:00
Alan Orth a0ee181361
dspace_statistics_api/docs/index.html: Fix whitespace 2020-09-25 13:33:45 +03:00
Alan Orth f58c209609
dspace_statistics_api/indexer.py: Update comment
I don't remember why we needed the stats, but it seems that it was
because without them there is no way to know how many results were
returned and therefore no way to know how many pages we'll need to
iterate over. Having the total number allows us to use a limit and
and offset to page through them deterministically.
2020-09-25 13:25:34 +03:00
Alan Orth 6dbff1e78f
README.md: Capitalize UUID 2020-09-25 13:03:15 +03:00
Alan Orth 731226ec15
README.md: Update for POST /items functionality 2020-09-25 13:01:09 +03:00
Alan Orth 2201d3df4e
dspace_statistics_api/docs/index.html: Minor HTML syntax issue 2020-09-25 12:55:39 +03:00
Alan Orth 2c0436f845
Update API docs HTML for /items POST functionality 2020-09-25 12:53:30 +03:00
Alan Orth f1e939481b
dspace_statistics_api/items.py: Remove shebang
This was originally a standalone script I was testing interactively.
2020-09-25 12:39:00 +03:00
Alan Orth 4d8026a3d0
Add missing dspace_statistics_api/items.py
This was meant to be added with the new /items POST changes.
2020-09-25 12:30:06 +03:00
Alan Orth de1f462ad2
CHANGELOG.md: Add more notes about /items 2020-09-25 12:29:19 +03:00
Alan Orth 8a6bbfd527
CHANGELOG.md: Add note about /items 2020-09-25 12:27:33 +03:00
Alan Orth 73c71fa8a0
dspace_statistics_api: Add support for date ranges to /items
You can now POST a JSON request to /items with a list of items and
a date range. This allows the possibility to get view and download
statistics for arbitrary items and arbitrary date ranges.

The JSON request should be in the following format:

    {
        "limit": 100,
        "page": 0,
        "dateFrom": "2020-01-01T00:00:00Z",
        "dateTo": "2020-09-09T00:00:00Z",
        "items": [
            "f44cf173-2344-4eb2-8f00-ee55df32c76f",
            "2324aa41-e9de-4a2b-bc36-16241464683e",
            "8542f9da-9ce1-4614-abf4-f2e3fdb4b305",
            "0fe573e7-042a-4240-a4d9-753b61233908"
        ]
    }

The limit, page, and date parameters are all optional. By default
it will use a limit of 100, page 0, and [* TO *] Solr date range.
2020-09-25 12:21:11 +03:00
Alan Orth 7a5e14716d
CHANGELOG.md: Add note about indexer refactoring 2020-09-24 12:08:21 +03:00
Alan Orth 21b500b4f7
dspace_statistics_api/util.py: Use docstring for get_statistics_shards
It seems better to use a docstring instead of a comment because it
can potentially be used by IDEs or documentation generators.
2020-09-24 12:07:31 +03:00
Alan Orth 495386856b
Refactor indexer
Move the get_statistics_shards() method to a utility module so it
can be used by other things.
2020-09-24 12:03:12 +03:00
Alan Orth 8e87f80e9a
dspace_statistics_api/indexer.py: Remove duplicate solr_url variable
This is declared twice and it never changes.
2020-09-24 11:54:31 +03:00
Alan Orth c4bf8bf698
README.md: Add TODO note about sorting by views or downloads 2020-09-24 11:53:23 +03:00
Alan Orth 6ff95bb5f2
dspace_statistics_api/indexer.py: Remove SolrClient reference
We stopped using SolrClient in favor of vanilla requests.
2020-09-24 11:30:31 +03:00
Alan Orth 0c8fb21f80
README.md: Update DSpace wiki URLs 2020-04-13 15:25:17 +03:00
Alan Orth b359c2466f
.travis.yml: Don't build in a container
I didn't realize these LXD containers are not available on AMD64.
Now I understand why the build was so slow: because it was ARM64!
2020-03-29 16:36:47 +03:00
Alan Orth 0eaed3e8c4
.travis.yml: Use Python 3.8-dev instead of master
See: https://docs.travis-ci.com/user/languages/python/#specifying-python-versions
2020-03-29 16:26:57 +03:00
Alan Orth 70e96214c8
.travis.yml: Go→Python
Fix incorrect language.
2020-03-29 16:24:39 +03:00
Alan Orth cab9f16dbc
.travis.yml: Try to run in an LXD container
According to the build environment documentation we need to specify
an OS of Linux in order to get a container instead of a VM.

See: https://docs.travis-ci.com/user/reference/overview/
2020-03-29 16:24:00 +03:00
Alan Orth bd49e1d1f6
.travis.yml: Correctly specify PostgreSQL 10
See: https://docs.travis-ci.com/user/database-setup/#postgresql
2020-03-29 16:19:08 +03:00
Alan Orth 144ed9a7c4
.travis.yml: Use PostgreSQL 10.0
Production is still PostgreSQL 9.6, but I have been using 10.0 in
local development and staging environments.
2020-03-29 16:08:29 +03:00
Alan Orth 48eef8c8e3
.travis.yml: Test on Python master
But allow failures!
2020-03-29 16:07:44 +03:00
Alan Orth fa9325e8a3 CHANGELOG.md: Add changes for v1.2.1 2020-03-02 14:32:07 +02:00
Alan Orth 998e833470 dspace_statistics_api/docs/index.html: Adjust help text 2020-03-02 14:30:16 +02:00
Alan Orth dd8252601f README.md: Adjust API help text 2020-03-02 14:29:13 +02:00
Alan Orth 9a9555853f README.md: Add note about versions 2020-03-02 14:28:22 +02:00
Alan Orth 385e92cc5e README.md: Update
Remove TODOs that I've recently completed and update introduction.
2020-03-02 14:25:47 +02:00
Alan Orth b0e6481961 tests/dspacestatistics.sql: Update
New database snapshot that uses UUIDs.
2020-03-02 12:36:06 +02:00
Alan Orth f96a903be3 README.md: Update Python requirement 2020-03-02 11:47:03 +02:00
Alan Orth fcf8fa4c29 CHANGELOG.md: Minor syntax and spelling changes 2020-03-02 11:45:26 +02:00
31 changed files with 4586 additions and 77959 deletions

View File

@ -1,24 +0,0 @@
image: archlinux
packages:
- python-pipenv
- postgresql
sources:
- https://git.sr.ht/~alanorth/dspace-statistics-api
tasks:
- setup: |
id
psql --version
sudo su - postgres -c "initdb --locale en_US.UTF-8 -E UTF8 -D '/var/lib/postgres/data'"
sudo systemctl start postgresql
createuser -U postgres dspacestatistics
psql -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
createdb -U postgres -O dspacestatistics --encoding=UNICODE dspacestatistics
cd dspace-statistics-api
psql -U postgres -d dspacestatistics < tests/dspacestatistics.sql
pipenv install --dev
- test: |
cd dspace-statistics-api
pipenv run pytest
environment:
PIPENV_NOSPIN: 'True'
PIPENV_HIDE_EMOJIS: 'True'

117
.drone.yml Normal file
View File

@ -0,0 +1,117 @@
kind: pipeline
type: docker
name: python310
steps:
- name: setup
image: postgres:15-alpine
environment:
PGPASSWORD: postgres
commands:
- id
- psql --version
- sleep 5
- pg_isready -h database -U postgres -d dspacestatistics
- createuser -h database -U postgres dspacestatistics
- psql -h database -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
- psql -h database -U postgres -d dspacestatistics < tests/dspacestatistics.sql
- name: test
image: python:3.10-slim
environment:
PGPASSWORD: dspacestatistics
DATABASE_HOST: database
commands:
- id
- python -V
- apt update && apt install -y gcc git libpq-dev
- pip install -r requirements-dev.txt
- pytest
services:
- name: database
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: dspacestatistics
---
kind: pipeline
type: docker
name: python39
steps:
- name: setup
image: postgres:15-alpine
environment:
PGPASSWORD: postgres
commands:
- id
- psql --version
- sleep 5
- pg_isready -h database -U postgres -d dspacestatistics
- createuser -h database -U postgres dspacestatistics
- psql -h database -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
- psql -h database -U postgres -d dspacestatistics < tests/dspacestatistics.sql
- name: test
image: python:3.9-slim
environment:
PGPASSWORD: dspacestatistics
DATABASE_HOST: database
commands:
- id
- python -V
- apt update && apt install -y gcc git libpq-dev
- pip install -r requirements-dev.txt
- pytest
services:
- name: database
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: dspacestatistics
---
kind: pipeline
type: docker
name: python38
steps:
- name: database
image: postgres:15-alpine
detach: true
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: dspacestatistics
- name: setup
image: postgres:15-alpine
environment:
PGPASSWORD: postgres
commands:
- id
- psql --version
- sleep 5
- pg_isready -h database -U postgres -d dspacestatistics
- createuser -h database -U postgres dspacestatistics
- psql -h database -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
- psql -h database -U postgres -d dspacestatistics < tests/dspacestatistics.sql
- name: test
image: python:3.8-slim
environment:
PGPASSWORD: dspacestatistics
DATABASE_HOST: database
commands:
- id
- python -V
- apt update && apt install -y gcc git libpq-dev
- pip install -r requirements-dev.txt
- pytest
# vim: ts=2 sw=2 et

58
.github/workflows/python-app.yml vendored Normal file
View File

@ -0,0 +1,58 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Build and Test
on: ['push', 'pull_request']
jobs:
build:
runs-on: ubuntu-22.04
services:
database:
image: postgres:15-alpine
env:
# password for postgres user in the Docker container
POSTGRES_PASSWORD: postgres
# default database to create
POSTGRES_DB: dspacestatistics
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'poetry'
- run: poetry install
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Set up PostgreSQL
run: |
pg_isready -U postgres -d dspacestatistics
createuser -U postgres dspacestatistics
psql -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
psql -U postgres -d dspacestatistics < tests/dspacestatistics.sql
env:
PGHOST: localhost
PGPASSWORD: postgres
- name: Test with pytest
run: |
poetry run pytest
env:
PGHOST: localhost
PGPASSWORD: dspacestatistics

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
__pycache__
venv
*.egg-info

View File

@ -1,4 +0,0 @@
flake8:
enabled: true
config_file: .flake8
fail_on_violations: true

View File

@ -1,20 +0,0 @@
dist: bionic
language: python
python:
- "3.6"
- "3.7"
- "3.8"
addons:
postgresql: "9.6"
before_script:
- psql --version
- createuser -U postgres dspacestatistics
- psql -U postgres -c "ALTER USER dspacestatistics WITH PASSWORD 'dspacestatistics'"
- createdb -U postgres -O dspacestatistics --encoding=UNICODE dspacestatistics
- psql -U postgres -d dspacestatistics < tests/dspacestatistics.sql
install:
- "pip install -r requirements.txt"
- "pip install -r requirements-dev.txt"
script: pytest
# vim: ts=2 sw=2 et

View File

@ -4,11 +4,90 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Update recommended Python version to 3.7+
- Use PostgreSQL 12 in CI
### Updated
- Falcon 3.1.1
## 1.4.3 - 2022-03-26
### Updated
- Update dependencies with `poetry update`
- Falcon 3.1.0, a minor change for us, but good to be using a current upstream
version
## 1.4.2 - 2021-04-14
### Updated
- Update dependencies with `poetry update`
- Falcon 3.0.0, a minor change for us, but good to be using a current upstream
version
### Fixed
- Bug in several of the "valid page" tests
### Added
- GitHub Actions workflow to build and test the API
## [1.4.1] - 2021-01-14
### Changed
- Limit Solr query to UUIDs to avoid errors with unmigrated legacy stats (https://github.com/ilri/dspace-statistics-api/issues/12)
### Updated
- Dev dependencies
## [1.4.0] - 2020-12-27
### Added
- indexer.py now indexes views and downloads for communities and collections
- API endpoints for /communities, /community/id, /collections, and /collections/id
- Swagger UI interface on /swagger
- /status page which lists the API version
### Changed
- Add ORDER BY to /items resource to make sure results are returned
deterministically
- Use `fl` parameter in indexer to return only the field we are faceting by
- Minor refactoring of imports for PEP8 style
- More correct calculation of `totalPages` parameter in REST API response
## [1.3.2] - 2020-11-18
### Fixed
- Minor issue with limit parameter (> 0)
- Minor issue with limit parameter (<= 100)
### Changed
- Minor refactor in Solr bot filtering
### Updated
- Run poetry update
## [1.3.1] - 2020-10-06
### Changed
- Fix issue with requirements.txt caused by poetry's export
## [1.3.0] - 2020-10-06
### Changed
- Minor refactoring of indexer
### Added
- Ability to get statistics for arbitrary items and date ranges by POSTing a JSON-formatted request to /items as opposed to the current `GET /items` which returns pre-indexed all-time stats for all items
### Updated
- Run pipenv update, bringing minor updates to pytest, psycopg2-binary, etc
## [1.2.1] - 2020-03-02
### Changed
- Help text in API docs should reference UUIDs
- Sample SQL file for tests should use UUIDs
## [1.2.0] - 2020-03-02
## Changed
### Changed
- Remove Python 3.5 from TravisCI because black requires Python >= 3.6
- Adapt API for DSpace 6+ UUIDs
- This requires droping the statistics database and re-indexing
- This requires dropping the statistics database and re-indexing
### Updated
- Run pipenv update, bringing requests 2.23.0 and pytest 5.3.5
## [1.1.1] - 2019-11-27
@ -26,7 +105,7 @@ and gunicorn 20.0.4
- Minor syntax issues highlighted by flake8
## [1.1.0] - 2019-05-05
## Updated
### Updated
- Falcon 2.0.0 (@alanorth)
## [1.0.0] - 2019-04-15
@ -44,7 +123,7 @@ and gunicorn 20.0.4
## [0.9.0] - 2019-01-22
### Updated
- pytest version 4.0.0
- Fix indexing of sharded statistics cores ([#10))
- Fix indexing of sharded statistics cores (#10)
- Handle case of missing views/downloads gracefully
## [0.8.1] - 2018-11-14

21
Pipfile
View File

@ -1,21 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
gunicorn = "*"
falcon = "==2.0.0"
"psycopg2-binary" = "*"
requests = "*"
[dev-packages]
ipython = "*"
"flake8" = "*"
pytest = "*"
isort = "*"
black = "==19.10b0"
pytest-clarity = "==0.3.0a0"
[requires]
python_version = "3.8"

419
Pipfile.lock generated
View File

@ -1,419 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "be968d3927117f9ac14b9a6f60d6147b2d57ce55f694f34ed6e53abcd2197823"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.8"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
"version": "==2019.11.28"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"falcon": {
"hashes": [
"sha256:18157af2a4fc3feedf2b5dcc6196f448639acf01c68bc33d4d5a04c3ef87f494",
"sha256:24adcd2b29a8ffa9d552dc79638cd21736a3fb04eda7d102c6cebafdaadb88ad",
"sha256:54f2cb4b687035b2a03206dbfc538055cc48b59a953187b0458aa1b574d47b53",
"sha256:59d1e8c993b9a37ea06df9d72cf907a46cc8063b30717cdac2f34d1658b6f936",
"sha256:733033ec80c896e30a43ab3e776856096836787197a44eb21022320a61311983",
"sha256:74cf1d18207381c665b9e6292d65100ce146d958707793174b03869dc6e614f4",
"sha256:95bf6ce986c1119aef12c9b348f4dee9c6dcc58391bdd0bc2b0bf353c2b15986",
"sha256:9712975adcf8c6e12876239085ad757b8fdeba223d46d23daef82b47658f83a9",
"sha256:a5ebb22a04c9cc65081938ee7651b4e3b4d2a28522ea8ec04c7bdd2b3e9e8cd8",
"sha256:aa184895d1ad4573fbfaaf803563d02f019ebdf4790e41cc568a330607eae439",
"sha256:e3782b7b92fefd46a6ad1fd8fe63fe6c6f1b7740a95ca56957f48d1aee34b357",
"sha256:e9efa0791b5d9f9dd9689015ea6bce0a27fcd5ecbcd30e6d940bffa4f7f03389",
"sha256:eea593cf466b9c126ce667f6d30503624ef24459f118c75594a69353b6c3d5fc",
"sha256:f93351459f110b4c1ee28556aef9a791832df6f910bea7b3f616109d534df06b"
],
"index": "pypi",
"version": "==2.0.0"
},
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
},
"idna": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
],
"version": "==2.9"
},
"psycopg2-binary": {
"hashes": [
"sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29",
"sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03",
"sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039",
"sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881",
"sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309",
"sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed",
"sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b",
"sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3",
"sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7",
"sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b",
"sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03",
"sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103",
"sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d",
"sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35",
"sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b",
"sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49",
"sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70",
"sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e",
"sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e",
"sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e",
"sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103",
"sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6",
"sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1",
"sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9",
"sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e",
"sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f",
"sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd",
"sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8",
"sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f",
"sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4",
"sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964",
"sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08"
],
"index": "pypi",
"version": "==2.8.4"
},
"requests": {
"hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
],
"index": "pypi",
"version": "==2.23.0"
},
"urllib3": {
"hashes": [
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
],
"version": "==1.25.8"
}
},
"develop": {
"appdirs": {
"hashes": [
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
],
"version": "==1.4.3"
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"version": "==19.3.0"
},
"backcall": {
"hashes": [
"sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4",
"sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2"
],
"version": "==0.1.0"
},
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
],
"index": "pypi",
"version": "==19.10b0"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"version": "==7.0"
},
"decorator": {
"hashes": [
"sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
"sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
],
"version": "==4.4.2"
},
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"flake8": {
"hashes": [
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
"version": "==3.7.9"
},
"ipython": {
"hashes": [
"sha256:ca478e52ae1f88da0102360e57e528b92f3ae4316aabac80a2cd7f7ab2efb48a",
"sha256:eb8d075de37f678424527b5ef6ea23f7b80240ca031c2dd6de5879d687a65333"
],
"index": "pypi",
"version": "==7.13.0"
},
"ipython-genutils": {
"hashes": [
"sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8",
"sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"
],
"version": "==0.2.0"
},
"isort": {
"hashes": [
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
],
"index": "pypi",
"version": "==4.3.21"
},
"jedi": {
"hashes": [
"sha256:b4f4052551025c6b0b0b193b29a6ff7bdb74c52450631206c262aef9f7159ad2",
"sha256:d5c871cb9360b414f981e7072c52c33258d598305280fef91c6cae34739d65d5"
],
"version": "==0.16.0"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"more-itertools": {
"hashes": [
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
],
"version": "==8.2.0"
},
"packaging": {
"hashes": [
"sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
"sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
],
"version": "==20.1"
},
"parso": {
"hashes": [
"sha256:0c5659e0c6eba20636f99a04f469798dca8da279645ce5c387315b2c23912157",
"sha256:8515fc12cfca6ee3aa59138741fc5624d62340c97e401c74875769948d4f2995"
],
"version": "==0.6.2"
},
"pathspec": {
"hashes": [
"sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
"sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
],
"version": "==0.7.0"
},
"pexpect": {
"hashes": [
"sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
"sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
],
"markers": "sys_platform != 'win32'",
"version": "==4.8.0"
},
"pickleshare": {
"hashes": [
"sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca",
"sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"
],
"version": "==0.7.5"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"prompt-toolkit": {
"hashes": [
"sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e",
"sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"
],
"version": "==3.0.3"
},
"ptyprocess": {
"hashes": [
"sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0",
"sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"
],
"version": "==0.6.0"
},
"py": {
"hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.1"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
],
"version": "==2.1.1"
},
"pygments": {
"hashes": [
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
],
"version": "==2.5.2"
},
"pyparsing": {
"hashes": [
"sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
"sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
],
"version": "==2.4.6"
},
"pytest": {
"hashes": [
"sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d",
"sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"
],
"index": "pypi",
"version": "==5.3.5"
},
"pytest-clarity": {
"hashes": [
"sha256:5cc99e3d9b7969dfe17e5f6072d45a917c59d363b679686d3c958a1ded2e4dcf"
],
"index": "pypi",
"version": "==0.3.0a0"
},
"regex": {
"hashes": [
"sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431",
"sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242",
"sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1",
"sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d",
"sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045",
"sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b",
"sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400",
"sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa",
"sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0",
"sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69",
"sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74",
"sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb",
"sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26",
"sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5",
"sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2",
"sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce",
"sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab",
"sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e",
"sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70",
"sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc",
"sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"
],
"version": "==2020.2.20"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"termcolor": {
"hashes": [
"sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"
],
"version": "==1.1.0"
},
"toml": {
"hashes": [
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
],
"version": "==0.10.0"
},
"traitlets": {
"hashes": [
"sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
"sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"
],
"version": "==4.3.3"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
],
"version": "==1.4.1"
},
"wcwidth": {
"hashes": [
"sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
"sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
],
"version": "==0.1.8"
}
}
}

View File

@ -1,7 +1,18 @@
# DSpace Statistics API [![Build Status](https://travis-ci.org/ilri/dspace-statistics-api.svg?branch=master)](https://travis-ci.org/ilri/dspace-statistics-api) [![builds.sr.ht status](https://builds.sr.ht/~alanorth/dspace-statistics-api.svg)](https://builds.sr.ht/~alanorth/dspace-statistics-api?)
DSpace stores item view and download events in a Solr "statistics" core. This information is available for use in the various DSpace user interfaces, but is not exposed externally via any APIs. The DSpace 4/5 [REST API](https://wiki.duraspace.org/display/DSDOC5x/REST+API), for example, only exposes information about communities, collections, item metadata, and bitstreams.
<h1 align="center">DSpace Statistics API</h1>
This project contains an indexer and a [Falcon-based](https://falcon.readthedocs.io/) web application to make the statistics available via a simple REST API. You can read more about the Solr queries used to gather the item view and download statistics on the [DSpace wiki](https://wiki.duraspace.org/display/DSPACE/Solr).
<p align="center">
<a href="https://ci.mjanja.ch/alanorth/dspace-statistics-api"><img alt="Build Status" src="https://ci.mjanja.ch/api/badges/alanorth/dspace-statistics-api/status.svg?ref=refs/heads/v6_x"></a>
<a href="https://github.com/ilri/dspace-statistics-api/actions"><img alt="Build and Test" src="https://github.com/ilri/dspace-statistics-api/actions/workflows/python-app.yml/badge.svg"></a>
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
</p>
DSpace stores item view and download events in a Solr "statistics" core. This information is available for use in the various DSpace user interfaces, but is not exposed externally via any APIs. The DSpace 4/5/6 [REST API](https://wiki.lyrasis.org/display/DSDOC5x/REST+API), for example, only exposes _metadata_ about communities, collections, items, and bitstreams.
- If your DSpace is version 4 or 5, use [dspace-statistics-api v1.1.1](https://github.com/ilri/dspace-statistics-api/releases/tag/v1.1.1)
- If your DSpace is version 6+, use [dspace-statistics-api v1.2.0 or greater](https://github.com/ilri/dspace-statistics-api/releases/tag/v1.2.0)
- Please make sure your statistics have been migrated from integers to UUIDs with the [solr-upgrade-statistics-6x](https://wiki.lyrasis.org/display/DSDOC6x/SOLR+Statistics+Maintenance) command
This project contains an indexer and a [Falcon-based](https://falcon.readthedocs.io/) web application to make the item, community, and collection statistics available via a simple REST API. You can read more about the Solr queries used to gather the item view and download statistics on the [DSpace wiki](https://wiki.lyrasis.org/display/DSPACE/Solr).
If you use the DSpace Statistics API please cite:
@ -9,9 +20,9 @@ If you use the DSpace Statistics API please cite:
## Requirements
- Python 3.5+
- Python 3.8+
- PostgreSQL version 9.5+ (due to [`UPSERT` support](https://wiki.postgresql.org/wiki/UPSERT))
- DSpace with [Solr usage statistics enabled](https://wiki.duraspace.org/display/DSDOC5x/SOLR+Statistics) (tested with 5.x)
- DSpace with [Solr usage statistics enabled](https://wiki.lyrasis.org/display/DSDOC5x/SOLR+Statistics) (tested with 5.8+ and 6.3)
## Installation
Create a Python virtual environment and install the dependencies:
@ -78,22 +89,44 @@ The API exposes the following endpoints:
- GET `/`return a basic API documentation page.
- GET `/items`return views and downloads for all items that Solr knows about¹. Accepts `limit` and `page` query parameters for pagination of results (`limit` must be an integer between 1 and 100, and `page` must be an integer greater than or equal to 0).
- GET `/item/id`return views and downloads for a single item (`id` must be a positive integer). Returns HTTP 404 if an item id is not found.
- POST `/items`return views and downloads for an arbitrary list of items with an optional date range. Accepts `limit`, `page`, `dateFrom`, and `dateTo` parameters².
- GET `/item/id`return views and downloads for a single item (`id` must be a UUID). Returns HTTP 404 if an item id is not found.
- GET `/communities`return views and downloads for all communities that Solr knows about¹. Accepts `limit` and `page` query parameters for pagination of results (`limit` must be an integer between 1 and 100, and `page` must be an integer greater than or equal to 0).
- POST `/communities`return views and downloads for an arbitrary list of communities with an optional date range. Accepts `limit`, `page`, `dateFrom`, and `dateTo` parameters².
- GET `/community/id`return views and downloads for a single community (`id` must be a UUID). Returns HTTP 404 if a community id is not found.
- GET `/collections`return views and downloads for all collections that Solr knows about¹. Accepts `limit` and `page` query parameters for pagination of results (`limit` must be an integer between 1 and 100, and `page` must be an integer greater than or equal to 0).
- POST `/collections`return views and downloads for an arbitrary list of collections with an optional date range. Accepts `limit`, `page`, `dateFrom`, and `dateTo` parameters².
- GET `/collection/id`return views and downloads for a single collection (`id` must be a UUID). Returns HTTP 404 if an collection id is not found.
The item id is the *internal* id for an item. You can get these from the standard DSpace REST API.
The id is the *internal* UUID for an item, community, or collection. You can get these from the standard DSpace REST API.
¹ We are querying the Solr statistics core, which technically only knows about items that have either views or downloads. If an item is not present here you can assume it has zero views and zero downloads, but not necessarily that it does not exist in the repository.
¹ We are querying the Solr statistics core, which technically only knows about items, communities, or collections that have either views or downloads. If an item, community, or collection is not present here you can assume it has zero views and zero downloads, but not necessarily that it does not exist in the repository.
## Todo
² POST requests to `/items`, `/communities`, and `/collections` should be in JSON format with the following parameters (substitute the "items" list for communities or collections accordingly):
```
{
"limit": 100, // optional, integer between 0 and 100, default 100
"page": 0, // optional, integer greater than 0, default 0
"dateFrom": "2020-01-01T00:00:00Z", // optional, default *
"dateTo": "2020-09-09T00:00:00Z", // optional, default *
"items": [
"f44cf173-2344-4eb2-8f00-ee55df32c76f",
"2324aa41-e9de-4a2b-bc36-16241464683e",
"8542f9da-9ce1-4614-abf4-f2e3fdb4b305",
"0fe573e7-042a-4240-a4d9-753b61233908"
]
}
```
## TODO
- Better logging
- Version API
- Version API (or at least include a /version endpoint?)
- Probably use /status with a version in the response
- Use JSON in PostgreSQL
- Add top items endpoint, perhaps `/top/items` or `/items/top`?
- Make community and collection stats available
- Support [DSpace 6 UUIDs](https://jira.duraspace.org/browse/DS-1782)
- Switch to [Python 3.6+ f-string syntax](https://realpython.com/python-f-strings/)
- Check IDs in database to see if they are deleted...
- Actually we could add `/items?limit=10&sort=views`
## License
This work is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html).

View File

@ -1,21 +1,77 @@
import falcon
# SPDX-License-Identifier: GPL-3.0-only
import json
import math
import falcon
import psycopg2.extras
from falcon_swagger_ui import register_swaggerui_app
from .config import DSPACE_STATISTICS_API_URL, VERSION
from .database import DatabaseManager
from .stats import get_downloads, get_views
from .util import set_statistics_scope, validate_post_parameters
class RootResource:
def on_get(self, req, resp):
resp.status = falcon.HTTP_200
resp.content_type = "text/html"
with open("dspace_statistics_api/docs/index.html", "r") as f:
resp.body = f.read()
docs_html = (
"<!DOCTYPE html>"
'<html lang="en-US">'
" <head>"
' <meta charset="UTF-8">'
" <title>DSpace Statistics API</title>"
" </head>"
" <body>"
f" <h1>DSpace Statistics API {VERSION}</h1>"
f" <p>This site is running the <a href=\"https://github.com/ilri/dspace-statistics-api\" title=\"DSpace Statistics API project\">DSpace Statistics API</a>. For more information see the project's README.md or the interactive <a href=\"{DSPACE_STATISTICS_API_URL + '/swagger'}\">Swagger UI</a> built into this API.</p>"
" </body>"
"</html"
)
resp.text = docs_html
class AllItemsResource:
class StatusResource:
def on_get(self, req, resp):
message = {"version": VERSION}
resp.status = falcon.HTTP_200
resp.media = message
class OpenAPIJSONResource:
def on_get(self, req, resp):
resp.status = falcon.HTTP_200
resp.content_type = "text/html"
with open("dspace_statistics_api/docs/openapi.json", "r") as f:
# Load the openapi.json schema
data = json.load(f)
# Swagger assumes your API is at the root of the current host unless
# you configure a "servers" block in the schema. The problem is that
# I want this to work in both development and production, so we need
# to make this configurable.
#
# If the DSPACE_STATISTICS_API_URL is configured then we will add a
# server entry to the openapi.json schema before sending it.
if DSPACE_STATISTICS_API_URL != "":
data["servers"] = [{"url": DSPACE_STATISTICS_API_URL}]
# Set the version in the schema so Swagger UI can display it
data["info"]["version"] = VERSION
resp.text = json.dumps(data)
class AllStatisticsResource:
@falcon.before(set_statistics_scope)
def on_get(self, req, resp):
"""Handles GET requests"""
# Return HTTPBadRequest if id parameter is not present and valid
limit = req.get_param_as_int("limit", min_value=0, max_value=100) or 100
limit = req.get_param_as_int("limit", min_value=1, max_value=100) or 100
page = req.get_param_as_int("page", min_value=0) or 0
offset = limit * page
@ -23,26 +79,26 @@ class AllItemsResource:
db.set_session(readonly=True)
with db.cursor() as cursor:
# get total number of items so we can estimate the pages
cursor.execute("SELECT COUNT(id) FROM items")
pages = round(cursor.fetchone()[0] / limit)
# get total number of communities/collections/items so we can estimate the pages
cursor.execute(f"SELECT COUNT(id) FROM {req.context.statistics_scope}")
pages = math.ceil(cursor.fetchone()[0] / limit)
# get statistics and use limit and offset to page through results
cursor.execute(
"SELECT id, views, downloads FROM items LIMIT %s OFFSET %s",
f"SELECT id, views, downloads FROM {req.context.statistics_scope} ORDER BY id LIMIT %s OFFSET %s",
[limit, offset],
)
# create a list to hold dicts of item stats
statistics = list()
# create a list to hold dicts of stats
statistics = []
# iterate over results and build statistics object
for item in cursor:
for result in cursor:
statistics.append(
{
"id": str(item["id"]),
"views": item["views"],
"downloads": item["downloads"],
"id": str(result["id"]),
"views": result["views"],
"downloads": result["downloads"],
}
)
@ -55,13 +111,80 @@ class AllItemsResource:
resp.media = message
@falcon.before(set_statistics_scope)
@falcon.before(validate_post_parameters)
def on_post(self, req, resp):
"""Handles POST requests.
class ItemResource:
def on_get(self, req, resp, item_id):
Uses two `before` hooks to set the statistics "scope" and validate the
POST parameters. The "scope" is the type of statistics we want, which
will be items, communities, or collections, depending on the request.
"""
# Build the Solr date string, ie: [* TO *]
if req.context.dateFrom and req.context.dateTo:
solr_date_string = f"[{req.context.dateFrom} TO {req.context.dateTo}]"
elif not req.context.dateFrom and req.context.dateTo:
solr_date_string = f"[* TO {req.context.dateTo}]"
elif req.context.dateFrom and not req.context.dateTo:
solr_date_string = f"[{req.context.dateFrom} TO *]"
else:
solr_date_string = "[* TO *]"
# Helper variables to make working with pages/items/results easier and
# to make the code easier to understand
number_of_elements: int = len(req.context.elements)
pages: int = math.ceil(number_of_elements / req.context.limit)
first_element: int = req.context.page * req.context.limit
last_element: int = first_element + req.context.limit
# Get a subset of the POSTed items based on our limit. Note that Python
# list slicing and indexing are both zero based, but the first and last
# items in a slice can be confusing. See this ASCII diagram:
#
# +---+---+---+---+---+---+
# | P | y | t | h | o | n |
# +---+---+---+---+---+---+
# Slice position: 0 1 2 3 4 5 6
# Index position: 0 1 2 3 4 5
#
# So if we have a list of items with 240 items:
#
# 1st set: items[0:100] would give items at indexes 0 to 99
# 2nd set: items[100:200] would give items at indexes 100 to 199
# 3rd set: items[200:300] would give items at indexes 200 to 239
elements_subset: list = req.context.elements[first_element:last_element]
views: dict = get_views(
solr_date_string, elements_subset, req.context.views_facet_field
)
downloads: dict = get_downloads(
solr_date_string, elements_subset, req.context.downloads_facet_field
)
# create a list to hold dicts of stats
statistics = []
# iterate over views dict to extract views and use the element id as an
# index to the downloads dict to extract downloads.
for k, v in views.items():
statistics.append({"id": k, "views": v, "downloads": downloads[k]})
message = {
"currentPage": req.context.page,
"totalPages": pages,
"limit": req.context.limit,
"statistics": statistics,
}
resp.status = falcon.HTTP_200
resp.media = message
class SingleStatisticsResource:
@falcon.before(set_statistics_scope)
def on_get(self, req, resp, id_):
"""Handles GET requests"""
import psycopg2.extras
# Adapt Pythons uuid.UUID type to PostgreSQLs uuid
# See: https://www.psycopg.org/docs/extras.html
psycopg2.extras.register_uuid()
@ -72,18 +195,19 @@ class ItemResource:
with db.cursor() as cursor:
cursor = db.cursor()
cursor.execute(
"SELECT views, downloads FROM items WHERE id=%s", [str(item_id)]
f"SELECT views, downloads FROM {req.context.database} WHERE id=%s",
[str(id_)],
)
if cursor.rowcount == 0:
raise falcon.HTTPNotFound(
title="Item not found",
description=f'The item with id "{str(item_id)}" was not found.',
title=f"{req.context.statistics_scope} not found",
description=f'The {req.context.statistics_scope} with id "{str(id_)}" was not found.',
)
else:
results = cursor.fetchone()
statistics = {
"id": str(item_id),
"id": str(id_),
"views": results["views"],
"downloads": results["downloads"],
}
@ -91,9 +215,45 @@ class ItemResource:
resp.media = statistics
api = application = falcon.API()
api.add_route("/", RootResource())
api.add_route("/items", AllItemsResource())
api.add_route("/item/{item_id:uuid}", ItemResource())
app = application = falcon.App()
app.add_route("/", RootResource())
app.add_route("/status", StatusResource())
# Item routes
app.add_route("/items", AllStatisticsResource())
app.add_route("/item/{id_:uuid}", SingleStatisticsResource())
# Community routes
app.add_route("/communities", AllStatisticsResource())
app.add_route("/community/{id_:uuid}", SingleStatisticsResource())
# Collection routes
app.add_route("/collections", AllStatisticsResource())
app.add_route("/collection/{id_:uuid}", SingleStatisticsResource())
# Route to the Swagger UI Openapp schema
app.add_route("/docs/openapi.json", OpenAPIJSONResource())
# Path to host the Swagger UI. Keep in mind that Falcon will add a route for
# this automatically when we register Swagger and the path will be relative
# to the Falcon app like all other routes, not the absolute root.
SWAGGERUI_PATH = "/swagger"
# The *absolute* path to the OpenJSON schema. This must be absolute because
# it will be requested by the client and must resolve absolutely. Note: the
# name of this variable is misleading because it is actually the schema URL
# but we pass it into the register_swaggerui_app() function as the app_url
# parameter.
SWAGGERUI_API_URL = f"{DSPACE_STATISTICS_API_URL}/docs/openapi.json"
register_swaggerui_app(
app,
SWAGGERUI_PATH,
SWAGGERUI_API_URL,
config={
"supportedSubmitMethods": ["get", "post"],
},
uri_prefix=DSPACE_STATISTICS_API_URL,
)
# vim: set sw=4 ts=4 expandtab:

View File

@ -1,3 +1,5 @@
# SPDX-License-Identifier: GPL-3.0-only
import os
# Check if Solr connection information was provided in the environment
@ -9,4 +11,13 @@ DATABASE_PASS = os.environ.get("DATABASE_PASS", "dspacestatistics")
DATABASE_HOST = os.environ.get("DATABASE_HOST", "localhost")
DATABASE_PORT = os.environ.get("DATABASE_PORT", "5432")
# URL to DSpace Statistics API, which will be used as a prefix to API calls in
# the Swagger UI. An empty string will allow this to work out of the box in a
# local development environment, but for production it should be set to a value
# like "/rest/statistics", assuming that the statistics API is deployed next to
# the vanilla DSpace REST API.
DSPACE_STATISTICS_API_URL = os.environ.get("DSPACE_STATISTICS_API_URL", "")
VERSION = "1.4.4-dev"
# vim: set sw=4 ts=4 expandtab:

View File

@ -1,3 +1,5 @@
# SPDX-License-Identifier: GPL-3.0-only
import falcon
import psycopg2
import psycopg2.extras

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>DSpace Statistics API</title>
</head>
<body>
<h1>DSpace Statistics API</h1>
<p>This site is running the <a href="https://github.com/ilri/dspace-statistics-api" title="DSpace Statistics API project">DSpace Statistics API</a>. The following endpoints are available:</p>
<ul>
<li>GET <code>/</code>return a basic API documentation page.</li>
<li>GET <code>/items</code>return views and downloads for all items that Solr knows about¹. Accepts <code>limit</code> and <code>page</code> query parameters for pagination of results (<code>limit</code> must be an integer between 1 and 100, and <code>page</code> must be an integer greater than or equal to 0).</li>
<li>GET <code>/item/id</code>return views and downloads for a single item (<code>id</code> must be a positive integer). Returns HTTP 404 if an item id is not found.</li>
</ul>
<p>The item id is the <em>internal</em> id for an item. You can get these from the standard DSpace REST API.</p>
<p>¹ We are querying the Solr statistics core, which technically only knows about items that have either views or downloads. If an item is not present here you can assume it has zero views and zero downloads, but not necessarily that it does not exist in the repository.</code>
</body>
</html>

View File

@ -0,0 +1,616 @@
{
"openapi": "3.0.3",
"info": {
"version": "1.4.4-dev",
"title": "DSpace Statistics API",
"description": "A [Falcon-based](https://falcon.readthedocs.io/) web application to make DSpace's item, community, and collection statistics available via a simple REST API. This Swagger interface is powered by [falcon-swagger-ui](https://github.com/rdidyk/falcon-swagger-ui).",
"license": {
"name": "GPLv3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.en.html"
}
},
"paths": {
"/item/{item_uuid}": {
"get": {
"summary": "Statistics for a specific item",
"operationId": "getItem",
"tags": [
"item"
],
"parameters": [
{
"name": "item_uuid",
"in": "path",
"required": true,
"description": "The UUID of the item to retrieve",
"schema": {
"type": "string",
"format": "uuid",
"example": "9596aeff-0b90-47d3-9fec-02d578920507"
}
}
],
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"404": {
"description": "Item not found"
}
}
}
},
"/items": {
"get": {
"summary": "Get statistics for all items",
"operationId": "getItems",
"tags": [
"items"
],
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many items to return at once (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100,
"example": 100
}
},
{
"name": "page",
"in": "query",
"description": "Page of results to start on (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0,
"example": 0
}
}
],
"responses": {
"200": {
"description": "A paged array of items",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"400": {
"description": "Bad request"
}
}
},
"post": {
"summary": "Get statistics for a list of items with an optional date range",
"operationId": "postItems",
"tags": [
"items"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0
},
"dateFrom": {
"type": "string",
"format": "date"
},
"dateTo": {
"type": "string",
"format": "date"
},
"items": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"example": {
"limit": 100,
"page": 0,
"dateFrom": "2020-01-01T00:00:00Z",
"dateTo": "2020-12-31T00:00:00Z",
"items": [
"f44cf173-2344-4eb2-8f00-ee55df32c76f",
"2324aa41-e9de-4a2b-bc36-16241464683e",
"8542f9da-9ce1-4614-abf4-f2e3fdb4b305",
"0fe573e7-042a-4240-a4d9-753b61233908"
]
}
}
}
}
},
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"currentPage": {
"type": "integer",
"format": "int32"
},
"limit": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
},
"statistics": {
"$ref": "#/components/schemas/ListOfElements"
}
}
}
}
}
},
"400": {
"description": "Bad request"
}
}
}
},
"/community/{community_uuid}": {
"get": {
"summary": "Statistics for a specific community",
"operationId": "getCommunity",
"tags": [
"community"
],
"parameters": [
{
"name": "community_uuid",
"in": "path",
"required": true,
"description": "The UUID of the community to retrieve",
"schema": {
"type": "string",
"format": "uuid",
"example": "bde7139c-d321-46bb-aef6-ae70799e5edb"
}
}
],
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"404": {
"description": "Community not found"
}
}
}
},
"/communities": {
"get": {
"summary": "Get statistics for all communities",
"operationId": "getCommunities",
"tags": [
"communities"
],
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many communities to return at once (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100,
"example": 100
}
},
{
"name": "page",
"in": "query",
"description": "Zero-based page of results to start on (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0,
"example": 0
}
}
],
"responses": {
"200": {
"description": "A paged array of communities",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"400": {
"description": "Bad request"
}
}
},
"post": {
"summary": "Get statistics for a list of communities with an optional date range",
"operationId": "postCommunities",
"tags": [
"communities"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0
},
"dateFrom": {
"type": "string",
"format": "date"
},
"dateTo": {
"type": "string",
"format": "date"
},
"communities": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"example": {
"limit": 100,
"page": 0,
"dateFrom": "2020-01-01T00:00:00Z",
"dateTo": "2020-12-31T00:00:00Z",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"8a8aeed1-077e-4360-bdf8-a5f3020193b1",
"47d0498a-203c-407d-afb8-1d44bf29badc",
"d3fe99a9-e27d-4035-9339-084c93228c82"
]
}
}
}
}
},
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"currentPage": {
"type": "integer",
"format": "int32"
},
"limit": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
},
"statistics": {
"$ref": "#/components/schemas/ListOfElements"
}
}
}
}
}
},
"400": {
"description": "Bad request"
}
}
}
},
"/collection/{collection_uuid}": {
"get": {
"summary": "Statistics for a specific collection",
"operationId": "getCollection",
"tags": [
"collection"
],
"parameters": [
{
"name": "collection_uuid",
"in": "path",
"required": true,
"description": "The UUID of the collection to retrieve",
"schema": {
"type": "string",
"format": "uuid",
"example": "49dc95d8-bf2f-4e68-b30f-41ea266c37ae"
}
}
],
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"404": {
"description": "Collection not found"
}
}
}
},
"/collections": {
"get": {
"summary": "Get statistics for all collections",
"operationId": "getCollections",
"tags": [
"collections"
],
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many collections to return at once (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100,
"example": 100
}
},
{
"name": "page",
"in": "query",
"description": "Zero-based page of results to start on (optional)",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0,
"example": 0
}
}
],
"responses": {
"200": {
"description": "A paged array of collections",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
},
"400": {
"description": "Bad request"
}
}
},
"post": {
"summary": "Get statistics for a list of collections with an optional date range",
"operationId": "postCollections",
"tags": [
"collections"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 1,
"maximum": 100,
"default": 100
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0,
"default": 0
},
"dateFrom": {
"type": "string",
"format": "date"
},
"dateTo": {
"type": "string",
"format": "date"
},
"collections": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"example": {
"limit": 100,
"page": 0,
"dateFrom": "2020-01-01T00:00:00Z",
"dateTo": "2020-12-31T00:00:00Z",
"collections": [
"5eeef6cf-b91b-42d0-9549-ea61bc8a758f",
"6aac3269-b4a9-4924-a24d-9e6ee2b410d2",
"551698dd-cd2b-4327-948e-54b5eb6deda5",
"39358713-bbaf-4149-a453-e2b18c09fd5d"
]
}
}
}
}
},
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"currentPage": {
"type": "integer",
"format": "int32"
},
"limit": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
},
"statistics": {
"$ref": "#/components/schemas/ListOfElements"
}
}
}
}
}
},
"400": {
"description": "Bad request"
}
}
}
},
"/status": {
"get": {
"summary": "Get API status",
"operationId": "getStatus",
"tags": [
"status"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"version": {
"type": "string",
"example": "1.4.0-dev"
}
}
}
}
}
},
"405": {
"description": "Method Not Allowed"
}
}
}
}
},
"components": {
"schemas": {
"SingleElementResponse": {
"type": "object",
"required": [
"id",
"views",
"downloads"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"views": {
"type": "integer",
"example": 450
},
"downloads": {
"type": "integer",
"example": 1337
}
}
},
"ListOfElements": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SingleElementResponse"
}
}
}
}
}

View File

@ -1,106 +1,46 @@
# SPDX-License-Identifier: GPL-3.0-only
#
# indexer.py
#
# Copyright 2018 Alan Orth.
# Connects to a DSpace Solr statistics core and ingests views and downloads for
# communities, collections, and items into a PostgreSQL database.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# ---
#
# Connects to a DSpace Solr statistics core and ingests item views and downloads
# into a PostgreSQL database for use by other applications (like an API).
#
# This script is written for Python 3.5+ and requires several modules that you
# This script is written for Python 3.6+ and requires several modules that you
# can install with pip (I recommend using a Python virtual environment):
#
# $ pip install SolrClient psycopg2-binary
# $ pip install psycopg2-binary
#
# See: https://solrclient.readthedocs.io/en/latest/SolrClient.html
# See: https://wiki.duraspace.org/display/DSPACE/Solr
import re
import math
import psycopg2.extras
import requests
from .config import SOLR_SERVER
from .database import DatabaseManager
from .util import get_statistics_shards
# Enumerate the cores in Solr to determine if statistics have been sharded into
# yearly shards by DSpace's stats-util or not (for example: statistics-2018).
def get_statistics_shards():
# Initialize an empty list for statistics core years
statistics_core_years = []
# URL for Solr status to check active cores
solr_query_params = {"action": "STATUS", "wt": "json"}
solr_url = SOLR_SERVER + "/admin/cores"
res = requests.get(solr_url, params=solr_query_params)
if res.status_code == requests.codes.ok:
data = res.json()
# Iterate over active cores from Solr's STATUS response (cores are in
# the status array of this response).
for core in data["status"]:
# Pattern to match, for example: statistics-2018
pattern = re.compile("^statistics-[0-9]{4}$")
if not pattern.match(core):
continue
# Append current core to list
statistics_core_years.append(core)
# Initialize a string to hold our shards (may end up being empty if the Solr
# core has not been processed by stats-util).
shards = str()
if len(statistics_core_years) > 0:
# Begin building a string of shards starting with the default one
shards = f"{SOLR_SERVER}/statistics"
for core in statistics_core_years:
# Create a comma-separated list of shards to pass to our Solr query
#
# See: https://wiki.apache.org/solr/DistributedSearch
shards += f",{SOLR_SERVER}/{core}"
# Return the string of shards, which may actually be empty. Solr doesn't
# seem to mind if the shards query parameter is empty and I haven't seen
# any negative performance impact so this should be fine.
return shards
def index_views():
def index_views(indexType: str, facetField: str):
# get total number of distinct facets for items with a minimum of 1 view,
# otherwise Solr returns all kinds of weird ids that are actually not in
# the database. Also, stats are expensive, but we need stats.calcdistinct
# so we can get the countDistinct summary.
# so we can get the countDistinct summary to calculate how many pages of
# results we have.
#
# see: https://lucene.apache.org/solr/guide/6_6/the-stats-component.html
solr_query_params = {
"q": "type:2",
"fq": "isBot:false AND statistics_type:view",
"q": f"type:2 AND {facetField}:/.{{36}}/",
"fq": "-isBot:true AND statistics_type:view",
"fl": facetField,
"facet": "true",
"facet.field": "id",
"facet.field": facetField,
"facet.mincount": 1,
"facet.limit": 1,
"facet.offset": 0,
"stats": "true",
"stats.field": "id",
"stats.field": facetField,
"stats.calcdistinct": "true",
"shards": shards,
"rows": 0,
@ -113,17 +53,17 @@ def index_views():
try:
# get total number of distinct facets (countDistinct)
results_totalNumFacets = res.json()["stats"]["stats_fields"]["id"][
results_totalNumFacets = res.json()["stats"]["stats_fields"][facetField][
"countDistinct"
]
except TypeError:
print("No item views to index, exiting.")
print(f"{indexType}: no views, exiting.")
exit(0)
# divide results into "pages" (cast to int to effectively round down)
# divide results into "pages" and round up to next integer
results_per_page = 100
results_num_pages = int(results_totalNumFacets / results_per_page)
results_num_pages = math.ceil(results_totalNumFacets / results_per_page)
results_current_page = 0
with DatabaseManager() as db:
@ -134,14 +74,15 @@ def index_views():
while results_current_page <= results_num_pages:
# "pages" are zero based, but one based is more human readable
print(
f"Indexing item views (page {results_current_page + 1} of {results_num_pages + 1})"
f"{indexType}: indexing views (page {results_current_page + 1} of {results_num_pages + 1})"
)
solr_query_params = {
"q": "type:2",
"fq": "isBot:false AND statistics_type:view",
"q": f"type:2 AND {facetField}:/.{{36}}/",
"fq": "-isBot:true AND statistics_type:view",
"fl": facetField,
"facet": "true",
"facet.field": "id",
"facet.field": facetField,
"facet.mincount": 1,
"facet.limit": results_per_page,
"facet.offset": results_current_page * results_per_page,
@ -151,18 +92,16 @@ def index_views():
"json.nl": "map", # return facets as a dict instead of a flat list
}
solr_url = SOLR_SERVER + "/statistics/select"
res = requests.get(solr_url, params=solr_query_params)
# Solr returns facets as a dict of dicts (see json.nl parameter)
views = res.json()["facet_counts"]["facet_fields"]
# iterate over the 'id' dict and get the item ids and views
for item_id, item_views in views["id"].items():
data.append((item_id, item_views))
# iterate over the facetField dict and get the ids and views
for id_, views in views[facetField].items():
data.append((id_, views))
# do a batch insert of values from the current "page" of results
sql = "INSERT INTO items(id, views) VALUES %s ON CONFLICT(id) DO UPDATE SET views=excluded.views"
sql = f"INSERT INTO {indexType}(id, views) VALUES %s ON CONFLICT(id) DO UPDATE SET views=excluded.views"
psycopg2.extras.execute_values(cursor, sql, data, template="(%s, %s)")
db.commit()
@ -172,18 +111,19 @@ def index_views():
results_current_page += 1
def index_downloads():
def index_downloads(indexType: str, facetField: str):
# get the total number of distinct facets for items with at least 1 download
solr_query_params = {
"q": "type:0",
"fq": "isBot:false AND statistics_type:view AND bundleName:ORIGINAL",
"q": f"type:0 AND {facetField}:/.{{36}}/",
"fq": "-isBot:true AND statistics_type:view AND bundleName:ORIGINAL",
"fl": facetField,
"facet": "true",
"facet.field": "owningItem",
"facet.field": facetField,
"facet.mincount": 1,
"facet.limit": 1,
"facet.offset": 0,
"stats": "true",
"stats.field": "owningItem",
"stats.field": facetField,
"stats.calcdistinct": "true",
"shards": shards,
"rows": 0,
@ -196,17 +136,16 @@ def index_downloads():
try:
# get total number of distinct facets (countDistinct)
results_totalNumFacets = res.json()["stats"]["stats_fields"]["owningItem"][
results_totalNumFacets = res.json()["stats"]["stats_fields"][facetField][
"countDistinct"
]
except TypeError:
print("No item downloads to index, exiting.")
print(f"{indexType}: no downloads, exiting.")
exit(0)
# divide results into "pages" (cast to int to effectively round down)
results_per_page = 100
results_num_pages = int(results_totalNumFacets / results_per_page)
results_num_pages = math.ceil(results_totalNumFacets / results_per_page)
results_current_page = 0
with DatabaseManager() as db:
@ -217,14 +156,15 @@ def index_downloads():
while results_current_page <= results_num_pages:
# "pages" are zero based, but one based is more human readable
print(
f"Indexing item downloads (page {results_current_page + 1} of {results_num_pages + 1})"
f"{indexType}: indexing downloads (page {results_current_page + 1} of {results_num_pages + 1})"
)
solr_query_params = {
"q": "type:0",
"fq": "isBot:false AND statistics_type:view AND bundleName:ORIGINAL",
"q": f"type:0 AND {facetField}:/.{{36}}/",
"fq": "-isBot:true AND statistics_type:view AND bundleName:ORIGINAL",
"fl": facetField,
"facet": "true",
"facet.field": "owningItem",
"facet.field": facetField,
"facet.mincount": 1,
"facet.limit": results_per_page,
"facet.offset": results_current_page * results_per_page,
@ -234,18 +174,16 @@ def index_downloads():
"json.nl": "map", # return facets as a dict instead of a flat list
}
solr_url = SOLR_SERVER + "/statistics/select"
res = requests.get(solr_url, params=solr_query_params)
# Solr returns facets as a dict of dicts (see json.nl parameter)
downloads = res.json()["facet_counts"]["facet_fields"]
# iterate over the 'owningItem' dict and get the item ids and downloads
for item_id, item_downloads in downloads["owningItem"].items():
data.append((item_id, item_downloads))
# iterate over the facetField dict and get the item ids and downloads
for id_, downloads in downloads[facetField].items():
data.append((id_, downloads))
# do a batch insert of values from the current "page" of results
sql = "INSERT INTO items(id, downloads) VALUES %s ON CONFLICT(id) DO UPDATE SET downloads=excluded.downloads"
sql = f"INSERT INTO {indexType}(id, downloads) VALUES %s ON CONFLICT(id) DO UPDATE SET downloads=excluded.downloads"
psycopg2.extras.execute_values(cursor, sql, data, template="(%s, %s)")
db.commit()
@ -262,13 +200,32 @@ with DatabaseManager() as db:
"""CREATE TABLE IF NOT EXISTS items
(id UUID PRIMARY KEY, views INT DEFAULT 0, downloads INT DEFAULT 0)"""
)
# create table to store community views and downloads
cursor.execute(
"""CREATE TABLE IF NOT EXISTS communities
(id UUID PRIMARY KEY, views INT DEFAULT 0, downloads INT DEFAULT 0)"""
)
# create table to store collection views and downloads
cursor.execute(
"""CREATE TABLE IF NOT EXISTS collections
(id UUID PRIMARY KEY, views INT DEFAULT 0, downloads INT DEFAULT 0)"""
)
# commit the table creation before closing the database connection
db.commit()
shards = get_statistics_shards()
index_views()
index_downloads()
# Index views and downloads for items, communities, and collections. Here the
# first parameter is the type of indexing to perform, and the second parameter
# is the field to facet by in Solr's statistics to get this information.
index_views("items", "id")
index_views("communities", "owningComm")
index_views("collections", "owningColl")
index_downloads("items", "owningItem")
index_downloads("communities", "owningComm")
index_downloads("collections", "owningColl")
# vim: set sw=4 ts=4 expandtab:

View File

@ -0,0 +1,126 @@
# SPDX-License-Identifier: GPL-3.0-only
import requests
from .config import SOLR_SERVER
from .util import get_statistics_shards
def get_views(solr_date_string: str, elements: list, facetField: str):
"""
Get view statistics for a list of elements from Solr. Depending on the req-
uest this could be items, communities, or collections.
:parameter solr_date_string (str): Solr date string, for example "[* TO *]"
:parameter elements (list): a list of IDs
:parameter facetField (str): Solr field to facet by, for example "id"
:returns: A dict of IDs and views
"""
shards = get_statistics_shards()
# Join the UUIDs with "OR" and escape the hyphens for Solr
solr_elements_string: str = " OR ".join(elements).replace("-", r"\-")
solr_query_params = {
"q": f"{facetField}:({solr_elements_string})",
"fq": f"type:2 AND -isBot:true AND statistics_type:view AND time:{solr_date_string}",
"fl": facetField,
"facet": "true",
"facet.field": facetField,
"facet.mincount": 1,
"shards": shards,
"rows": 0,
"wt": "json",
"json.nl": "map", # return facets as a dict instead of a flat list
}
solr_url = SOLR_SERVER + "/statistics/select"
res = requests.get(solr_url, params=solr_query_params)
# Create an empty dict to store views
data = {}
# Solr returns facets as a dict of dicts (see the json.nl parameter)
views = res.json()["facet_counts"]["facet_fields"]
# iterate over the facetField dict and ids and views
for id_, views in views[facetField].items():
# For items we can rely on Solr returning facets for the *only* the ids
# in our query, but for communities and collections, the owningComm and
# owningColl fields are multi-value so Solr will return facets with the
# values in our query as well as *any others* that happen to be present
# in the field (which looks like Solr returning unrelated results until
# you realize that the field is multi-value and this is correct).
#
# To work around this I make sure that each id in the returned dict are
# present in the elements list POSTed by the user.
if id_ in elements:
data[id_] = views
# Check if any ids have missing stats so we can set them to 0
if len(data) < len(elements):
# List comprehension to get a list of ids (keys) in the data
data_ids = [k for k, v in data.items()]
for element_id in elements:
if element_id not in data_ids:
data[element_id] = 0
continue
return data
def get_downloads(solr_date_string: str, elements: list, facetField: str):
"""
Get download statistics for a list of items from Solr. Depending on the req-
uest this could be items, communities, or collections.
:parameter solr_date_string (str): Solr date string, for example "[* TO *]"
:parameter elements (list): a list of IDs
:parameter facetField (str): Solr field to facet by, for example "id"
:returns: A dict of IDs and downloads
"""
shards = get_statistics_shards()
# Join the UUIDs with "OR" and escape the hyphens for Solr
solr_elements_string: str = " OR ".join(elements).replace("-", r"\-")
solr_query_params = {
"q": f"{facetField}:({solr_elements_string})",
"fq": f"type:0 AND -isBot:true AND statistics_type:view AND bundleName:ORIGINAL AND time:{solr_date_string}",
"fl": facetField,
"facet": "true",
"facet.field": facetField,
"facet.mincount": 1,
"shards": shards,
"rows": 0,
"wt": "json",
"json.nl": "map", # return facets as a dict instead of a flat list
}
solr_url = SOLR_SERVER + "/statistics/select"
res = requests.get(solr_url, params=solr_query_params)
# Create an empty dict to store downloads
data = {}
# Solr returns facets as a dict of dicts (see the json.nl parameter)
downloads = res.json()["facet_counts"]["facet_fields"]
# Iterate over the facetField dict and get the ids and downloads
for id_, downloads in downloads[facetField].items():
# Make sure that each id in the returned dict are present in the
# elements list POSTed by the user.
if id_ in elements:
data[id_] = downloads
# Check if any elements have missing stats so we can set them to 0
if len(data) < len(elements):
# List comprehension to get a list of ids (keys) in the data
data_ids = [k for k, v in data.items()]
for element_id in elements:
if element_id not in data_ids:
data[element_id] = 0
continue
return data
# vim: set sw=4 ts=4 expandtab:

View File

@ -0,0 +1,193 @@
# SPDX-License-Identifier: GPL-3.0-only
import datetime
import json
import re
import falcon
import requests
from .config import SOLR_SERVER
def get_statistics_shards():
"""Enumerate the cores in Solr to determine if statistics have been sharded into
yearly shards by DSpace's stats-util or not (for example: statistics-2018).
Returns:
str:A list of Solr statistics shards separated by commas.
"""
# Initialize an empty list for statistics core years
statistics_core_years = []
# URL for Solr status to check active cores
solr_query_params = {"action": "STATUS", "wt": "json"}
solr_url = SOLR_SERVER + "/admin/cores"
res = requests.get(solr_url, params=solr_query_params)
if res.status_code == requests.codes.ok:
data = res.json()
# Iterate over active cores from Solr's STATUS response (cores are in
# the status array of this response).
for core in data["status"]:
# Pattern to match, for example: statistics-2018
pattern = re.compile("^statistics-[0-9]{4}$")
if not pattern.match(core):
continue
# Append current core to list
statistics_core_years.append(core)
# Initialize a string to hold our shards (may end up being empty if the Solr
# core has not been processed by stats-util).
shards = str()
if len(statistics_core_years) > 0:
# Begin building a string of shards starting with the default one
shards = f"{SOLR_SERVER}/statistics"
for core in statistics_core_years:
# Create a comma-separated list of shards to pass to our Solr query
#
# See: https://wiki.apache.org/solr/DistributedSearch
shards += f",{SOLR_SERVER}/{core}"
# Return the string of shards, which may actually be empty. Solr doesn't
# seem to mind if the shards query parameter is empty and I haven't seen
# any negative performance impact so this should be fine.
return shards
def is_valid_date(date):
try:
# Solr date format is: 2020-01-01T00:00:00Z
# See: https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
return True
except ValueError:
raise falcon.HTTPBadRequest(
title="Invalid parameter",
description=f"Invalid date format: {date}. The value must be in format: 2020-01-01T00:00:00Z.",
)
def validate_post_parameters(req, resp, resource, params):
"""Check the POSTed request parameters for the `/items`, `/communities` and
`/collections` endpoints.
Meant to be used as a `before` hook.
"""
# Only attempt to read the POSTed request if its length is not 0 (or
# rather, in the Python sense, if length is not a False-y value).
if req.content_length:
doc = json.load(req.bounded_stream)
else:
raise falcon.HTTPBadRequest(
title="Invalid request", description="Request body is empty."
)
# Parse date parameters from request body (will raise an HTTPBadRequest
# from is_valid_date() if any parameters are invalid)
if "dateFrom" in doc and is_valid_date(doc["dateFrom"]):
req.context.dateFrom = doc["dateFrom"]
else:
req.context.dateFrom = None
if "dateTo" in doc and is_valid_date(doc["dateTo"]):
req.context.dateTo = doc["dateTo"]
else:
req.context.dateTo = None
# Parse the limit parameter from the POST request body
if "limit" in doc:
if isinstance(doc["limit"], int) and 0 < doc["limit"] <= 100:
req.context.limit = doc["limit"]
else:
raise falcon.HTTPBadRequest(
title="Invalid parameter",
description='The "limit" parameter is invalid. The value must be an integer between 1 and 100.',
)
else:
req.context.limit = 100
# Parse the page parameter from the POST request body
if "page" in doc:
if isinstance(doc["page"], int) and doc["page"] >= 0:
req.context.page = doc["page"]
else:
raise falcon.HTTPBadRequest(
title="Invalid parameter",
description='The "page" parameter is invalid. The value must be at least 0.',
)
else:
req.context.page = 0
# Parse the list of elements from the POST request body
if req.context.statistics_scope in doc:
if (
isinstance(doc[req.context.statistics_scope], list)
and len(doc[req.context.statistics_scope]) > 0
):
req.context.elements = doc[req.context.statistics_scope]
else:
raise falcon.HTTPBadRequest(
title="Invalid parameter",
description=f'The "{req.context.statistics_scope}" parameter is invalid. The value must be a comma-separated list of UUIDs.',
)
else:
req.context.elements = []
def set_statistics_scope(req, resp, resource, params):
"""Set the statistics scope (item, collection, or community) of the request
as well as the appropriate database (for GET requests) and Solr facet fields
(for POST requests).
Meant to be used as a `before` hook.
"""
# Extract the scope from the request path. This is *guaranteed* to be one
# of the following values because we only send requests matching these few
# patterns to routes using this set_statistics_scope hook.
#
# Note: this regex is ordered so that "items" and "collections" match before
# "item" and "collection".
req.context.statistics_scope = re.findall(
r"^/(communities|community|collections|collection|items|item)", req.path
)[0]
# Set the correct database based on the statistics_scope. The database is
# used for all GET requests where statistics are returned directly from the
# database. In this case we can return early.
if req.method == "GET":
if re.findall(r"^(item|items)$", req.context.statistics_scope):
req.context.database = "items"
elif re.findall(r"^(community|communities)$", req.context.statistics_scope):
req.context.database = "communities"
elif re.findall(r"^(collection|collections)$", req.context.statistics_scope):
req.context.database = "collections"
# GET requests only need the scope and the database so we can return now
return
# If the current request is for a plural items, communities, or collections
# that includes a list of element ids POSTed with the request body then we
# need to set the Solr facet field so we can get the live results.
if req.method == "POST":
if req.context.statistics_scope == "items":
req.context.views_facet_field = "id"
req.context.downloads_facet_field = "owningItem"
elif req.context.statistics_scope == "communities":
req.context.views_facet_field = "owningComm"
req.context.downloads_facet_field = "owningComm"
elif req.context.statistics_scope == "collections":
req.context.views_facet_field = "owningColl"
req.context.downloads_facet_field = "owningColl"
# vim: set sw=4 ts=4 expandtab:

773
poetry.lock generated Normal file
View File

@ -0,0 +1,773 @@
# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand.
[[package]]
name = "black"
version = "23.12.1"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"},
{file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"},
{file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"},
{file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"},
{file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"},
{file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"},
{file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"},
{file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"},
{file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"},
{file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"},
{file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"},
{file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"},
{file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"},
{file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"},
{file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"},
{file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"},
{file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"},
{file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"},
{file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"},
{file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"},
{file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"},
{file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2023.5.7"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
{file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
]
[[package]]
name = "charset-normalizer"
version = "3.1.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7.0"
files = [
{file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"},
{file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"},
{file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"},
{file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"},
{file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"},
{file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"},
{file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"},
{file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"},
{file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"},
{file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"},
{file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"},
{file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"},
{file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"},
{file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"},
{file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"},
{file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"},
{file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"},
{file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"},
{file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"},
{file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"},
{file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"},
{file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"},
{file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"},
{file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"},
{file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"},
{file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"},
{file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"},
{file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"},
{file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"},
{file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"},
{file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"},
{file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"},
{file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"},
{file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"},
{file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"},
{file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"},
{file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"},
{file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"},
{file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"},
{file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"},
{file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"},
{file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"},
{file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"},
{file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"},
{file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"},
{file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"},
{file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"},
{file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"},
{file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"},
{file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"},
{file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"},
{file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"},
{file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"},
{file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"},
{file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"},
{file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"},
{file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"},
{file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"},
{file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"},
{file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"},
{file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"},
{file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"},
{file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "exceptiongroup"
version = "1.1.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
{file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "falcon"
version = "3.1.3"
description = "The ultra-reliable, fast ASGI+WSGI framework for building data plane APIs at scale."
optional = false
python-versions = ">=3.5"
files = [
{file = "falcon-3.1.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:094d295a767e2aa84f07bec6b23e9ebe2e43cde81d9d583bef037168bd775ad6"},
{file = "falcon-3.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b203408040e87e8323e1c1921b106353fa5fe5dc05c9b3f4881acb3af03f556"},
{file = "falcon-3.1.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d56d9a9886387585ce4547354c9929bf5743394df04a17df6ed51ad6bb58a4cc"},
{file = "falcon-3.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c335f1118a6e42f08cf30d56914a0bc0d470aa6db7619fdc4c546b184f38248"},
{file = "falcon-3.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:cb6b6a79d096b3a1f2f37f66f46a2cf18deb575db6dee9935057e6036d98d01f"},
{file = "falcon-3.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:508fdf30617cf1fa5c9d3058c14124dc8e5f7e316e26dca22d974f916493fd0e"},
{file = "falcon-3.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca3c6cbcba90e272f60581fb3c4561cdcd0ac6d19672f5a11a04309b1d23fa66"},
{file = "falcon-3.1.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7471aab646875d4478377065246a4115aaf3c0801a6eb4b6871f9836c8ef60b1"},
{file = "falcon-3.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bbbfa1ecb1d50bed9f8ae940b0f1049d958e945f1a08891769d40cfabe6fb2"},
{file = "falcon-3.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:24aa51ba4145f05649976c33664971ef36f92846208bd9d4d4158ceb51bc753f"},
{file = "falcon-3.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7a1ee54bf19d9c7f998edd8ac21ab8ead1e2f73c24822237eb5485890979a25d"},
{file = "falcon-3.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db78171113a3920f0f33d8dd26364527a362db2d1c3376a95778653ff87dea24"},
{file = "falcon-3.1.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:656e738e0e220f4503e4f07747b564f4459da159a1f32ec6d2478efb651278dd"},
{file = "falcon-3.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e19a0a3827821bcf754a9b24217e3b8b4750f7eb437c4a8c461135a86ca9b1c5"},
{file = "falcon-3.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:d52a05be5c2ef364853cdc6d97056dd880a534016db73b95f5a6ebc652577533"},
{file = "falcon-3.1.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d78a6cfe2d135632673def489a19474e2508d83475c7662c4fa63be0ba82dd81"},
{file = "falcon-3.1.3-cp36-cp36m-win_amd64.whl", hash = "sha256:adc23ced91c4690042a11a0515c5cfe93eeeb7d063940900aee85f8eae7460ec"},
{file = "falcon-3.1.3-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:d6b7131e85dff13abaacb4ff479c456256f0d57b262b1fb1771180f7535cc902"},
{file = "falcon-3.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d51f556ece73766f07ede57f17fa65dbbc2cc5e1c7075fb606f727464ad71e"},
{file = "falcon-3.1.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b210c05b38a8d655e16aa3ae2befaa70ecfb49bef73c0c1995566b22afcfdd1"},
{file = "falcon-3.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04a92f159d392098a11d14b8ca71d17129d8b1ef37b7a3577f1f8bcb7b3aecba"},
{file = "falcon-3.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9c82cb54bbf67861febe80d394c9b7bfa0d2e16cc998b69bfff4e8b003c721a2"},
{file = "falcon-3.1.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:56e8a4728fb0193e2ccd5301d864fd9743a989cc228e709e5c49ff1025cc1a4f"},
{file = "falcon-3.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12432c3f6bce46fe4eec3db6db8d2df1abe43a7531219356f1ba859db207e57b"},
{file = "falcon-3.1.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1f622d73111912021b8311d1e5d1eabef484217d2d30abe3d237533cb225ce9"},
{file = "falcon-3.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19b2ce8a613a29a9eaf8243ca285ebf80464e8a6489dff60425f850fb5548936"},
{file = "falcon-3.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:3cda76fb21568aa058ce454fa6272ca5b2582ebb0efcb7ae0090d3bf6d0db5af"},
{file = "falcon-3.1.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:cbd40435e99255e40ccfa849e4809cd1638fd8eccc08931fc9d355a6840a7332"},
{file = "falcon-3.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6319883789ee3abcbde2dc10fed8016cc3d9a05018ae59944838b892101111a"},
{file = "falcon-3.1.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:796a57046b0717bff5ac488235c37ea63834a5cfc2c9291c5eeaa43c53e5e24c"},
{file = "falcon-3.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2fe54081f1cedc71462eff8dca074045d14380a4bca163882c6c4353f65af2"},
{file = "falcon-3.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:ad37c46322122f34e228be4fe7ae5fcfedb630eef788a198fbdff5971091d5dc"},
{file = "falcon-3.1.3.tar.gz", hash = "sha256:23335dbccd44f29e85ec55f2f35d5a0bc12bd7a509f641ab81f5c64b65626263"},
]
[[package]]
name = "falcon-swagger-ui"
version = "1.2.1"
description = "Swagger UI Application for Falcon"
optional = false
python-versions = "*"
files = []
develop = false
[package.dependencies]
falcon = "*"
Jinja2 = "*"
[package.source]
type = "git"
url = "https://github.com/alanorth/falcon-swagger-ui.git"
reference = "falcon3-update-swagger-ui"
resolved_reference = "c019c270b479c03d9276e20fd95488495b0943f6"
[[package]]
name = "fixit"
version = "2.1.0"
description = "A lint framework that writes better Python code for you."
optional = false
python-versions = ">=3.8"
files = [
{file = "fixit-2.1.0-py3-none-any.whl", hash = "sha256:76b286c0abb9d6a63e5c7d1b6673a041c4356e93d70472e94a9ad2c447da7753"},
{file = "fixit-2.1.0.tar.gz", hash = "sha256:b31665cb6491d659d8dfef5a6078a7e9f786e299826636d03d6bd91b6f71e95b"},
]
[package.dependencies]
click = ">=8.0"
libcst = ">=0.3.18"
moreorless = ">=0.4.0"
packaging = ">=21"
tomli = {version = ">=2.0", markers = "python_version < \"3.11\""}
trailrunner = ">=1.2"
[package.extras]
dev = ["attribution (==1.6.2)", "black (==23.9.1)", "flake8 (==6.1.0)", "flake8-bugbear (==23.9.16)", "mypy (==1.5.1)", "ufmt (==2.2.0)", "usort (==1.0.7)"]
docs = ["jinja2 (==3.1.2)", "sphinx (==7.0.1)", "sphinx-mdinclude (==0.5.3)"]
pretty = ["rich (>=12.6.0)"]
[[package]]
name = "flake8"
version = "7.0.0"
description = "the modular source code checker: pep8 pyflakes and co"
optional = false
python-versions = ">=3.8.1"
files = [
{file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"},
{file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"},
]
[package.dependencies]
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.11.0,<2.12.0"
pyflakes = ">=3.2.0,<3.3.0"
[[package]]
name = "gunicorn"
version = "21.2.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.5"
files = [
{file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"},
{file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"},
]
[package.dependencies]
packaging = "*"
[package.extras]
eventlet = ["eventlet (>=0.24.1)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
tornado = ["tornado (>=0.2)"]
[[package]]
name = "idna"
version = "3.4"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "isort"
version = "5.13.2"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.8.0"
files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
]
[package.extras]
colors = ["colorama (>=0.4.6)"]
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
files = [
{file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "libcst"
version = "1.1.0"
description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs."
optional = false
python-versions = ">=3.8"
files = [
{file = "libcst-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:63f75656fd733dc20354c46253fde3cf155613e37643c3eaf6f8818e95b7a3d1"},
{file = "libcst-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ae11eb1ea55a16dc0cdc61b41b29ac347da70fec14cc4381248e141ee2fbe6c"},
{file = "libcst-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bc745d0c06420fe2644c28d6ddccea9474fb68a2135904043676deb4fa1e6bc"},
{file = "libcst-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c1f2da45f1c45634090fd8672c15e0159fdc46853336686959b2d093b6e10fa"},
{file = "libcst-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:003e5e83a12eed23542c4ea20fdc8de830887cc03662432bb36f84f8c4841b81"},
{file = "libcst-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3ebbb9732ae3cc4ae7a0e97890bed0a57c11d6df28790c2b9c869f7da653c7c7"},
{file = "libcst-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d68c34e3038d3d1d6324eb47744cbf13f2c65e1214cf49db6ff2a6603c1cd838"},
{file = "libcst-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dffa1795c2804d183efb01c0f1efd20a7831db6a21a0311edf90b4100d67436"},
{file = "libcst-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc9b6ac36d7ec9db2f053014ea488086ca2ed9c322be104fbe2c71ca759da4bb"},
{file = "libcst-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b7a38ec4c1c009ac39027d51558b52851fb9234669ba5ba62283185963a31c"},
{file = "libcst-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5297a16e575be8173185e936b7765c89a3ca69d4ae217a4af161814a0f9745a7"},
{file = "libcst-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:7ccaf53925f81118aeaadb068a911fac8abaff608817d7343da280616a5ca9c1"},
{file = "libcst-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:75816647736f7e09c6120bdbf408456f99b248d6272277eed9a58cf50fb8bc7d"},
{file = "libcst-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8f26250f87ca849a7303ed7a4fd6b2c7ac4dec16b7d7e68ca6a476d7c9bfcdb"},
{file = "libcst-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d37326bd6f379c64190a28947a586b949de3a76be00176b0732c8ee87d67ebe"},
{file = "libcst-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3d8cf974cfa2487b28f23f56c4bff90d550ef16505e58b0dca0493d5293784b"},
{file = "libcst-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d1271403509b0a4ee6ff7917c2d33b5a015f44d1e208abb1da06ba93b2a378"},
{file = "libcst-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bca1841693941fdd18371824bb19a9702d5784cd347cb8231317dbdc7062c5bc"},
{file = "libcst-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f36f592e035ef84f312a12b75989dde6a5f6767fe99146cdae6a9ee9aff40dd0"},
{file = "libcst-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f561c9a84eca18be92f4ad90aa9bd873111efbea995449301719a1a7805dbc5c"},
{file = "libcst-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97fbc73c87e9040e148881041fd5ffa2a6ebf11f64b4ccb5b52e574b95df1a15"},
{file = "libcst-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99fdc1929703fd9e7408aed2e03f58701c5280b05c8911753a8d8619f7dfdda5"},
{file = "libcst-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bf69cbbab5016d938aac4d3ae70ba9ccb3f90363c588b3b97be434e6ba95403"},
{file = "libcst-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:fe41b33aa73635b1651f64633f429f7aa21f86d2db5748659a99d9b7b1ed2a90"},
{file = "libcst-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73c086705ed34dbad16c62c9adca4249a556c1b022993d511da70ea85feaf669"},
{file = "libcst-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a07ecfabbbb8b93209f952a365549e65e658831e9231649f4f4e4263cad24b1"},
{file = "libcst-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c653d9121d6572d8b7f8abf20f88b0a41aab77ff5a6a36e5a0ec0f19af0072e8"},
{file = "libcst-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f1cd308a4c2f71d5e4eec6ee693819933a03b78edb2e4cc5e3ad1afd5fb3f07"},
{file = "libcst-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8afb6101b8b3c86c5f9cec6b90ab4da16c3c236fe7396f88e8b93542bb341f7c"},
{file = "libcst-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:d22d1abfe49aa60fc61fa867e10875a9b3024ba5a801112f4d7ba42d8d53242e"},
{file = "libcst-1.1.0.tar.gz", hash = "sha256:0acbacb9a170455701845b7e940e2d7b9519db35a86768d86330a0b0deae1086"},
]
[package.dependencies]
pyyaml = ">=5.2"
typing-extensions = ">=3.7.4.2"
typing-inspect = ">=0.4.0"
[package.extras]
dev = ["Sphinx (>=5.1.1)", "black (==23.9.1)", "build (>=0.10.0)", "coverage (>=4.5.4)", "fixit (==2.0.0.post1)", "flake8 (>=3.7.8,<5)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jinja2 (==3.1.2)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.16)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "pyre-check (==0.9.18)", "setuptools-rust (>=1.5.2)", "setuptools-scm (>=6.0.1)", "slotscheck (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==2.2.0)", "usort (==1.0.7)"]
[[package]]
name = "markupsafe"
version = "2.1.2"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"},
{file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"},
{file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"},
{file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"},
{file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"},
{file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"},
{file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"},
{file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"},
{file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"},
{file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"},
{file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"},
{file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"},
{file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"},
{file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"},
{file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"},
{file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"},
{file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"},
{file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"},
]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "moreorless"
version = "0.4.0"
description = "Python diff wrapper"
optional = false
python-versions = ">=3.6"
files = [
{file = "moreorless-0.4.0-py2.py3-none-any.whl", hash = "sha256:17f1fbef60fd21c84ee085a929fe3acefcaddca30df5dd09c024e9939a9e6a00"},
{file = "moreorless-0.4.0.tar.gz", hash = "sha256:85e19972c1a0b3a49f8543914f57bd83f6e1b10df144d5b97b8c5e9744d9c08c"},
]
[package.dependencies]
click = "*"
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.7"
files = [
{file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
{file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
]
[[package]]
name = "pathspec"
version = "0.11.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.7"
files = [
{file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
{file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
]
[[package]]
name = "platformdirs"
version = "3.5.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"},
{file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"},
]
[package.extras]
docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.6"
files = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "psycopg2"
version = "2.9.9"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
optional = false
python-versions = ">=3.7"
files = [
{file = "psycopg2-2.9.9-cp310-cp310-win32.whl", hash = "sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516"},
{file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"},
{file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"},
{file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"},
{file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"},
{file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"},
{file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"},
{file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"},
{file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"},
{file = "psycopg2-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e"},
{file = "psycopg2-2.9.9-cp39-cp39-win32.whl", hash = "sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59"},
{file = "psycopg2-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913"},
{file = "psycopg2-2.9.9.tar.gz", hash = "sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156"},
]
[[package]]
name = "pycodestyle"
version = "2.11.0"
description = "Python style guide checker"
optional = false
python-versions = ">=3.8"
files = [
{file = "pycodestyle-2.11.0-py2.py3-none-any.whl", hash = "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8"},
{file = "pycodestyle-2.11.0.tar.gz", hash = "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0"},
]
[[package]]
name = "pyflakes"
version = "3.2.0"
description = "passive checker of Python programs"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"},
{file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
]
[[package]]
name = "pytest"
version = "7.4.4"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
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 = "pyyaml"
version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "trailrunner"
version = "1.4.0"
description = "Run things on paths"
optional = false
python-versions = ">=3.7"
files = [
{file = "trailrunner-1.4.0-py3-none-any.whl", hash = "sha256:a286d39f2723f28d167347f41cf8f232832648709366e722f55cf5545772a48e"},
{file = "trailrunner-1.4.0.tar.gz", hash = "sha256:3fe61e259e6b2e5192f321c265985b7a0dc18497ced62b2da244f08104978398"},
]
[package.dependencies]
pathspec = ">=0.8.1"
[package.extras]
dev = ["attribution (==1.6.2)", "black (==22.3.0)", "click (==8.1.3)", "coverage (==6.5)", "flake8 (==4.0.1)", "flake8-bugbear (==23.2.13)", "flit (==3.7.1)", "mypy (==1.1.1)", "rich (==13.3.2)", "ufmt (==2.0.1)", "usort (==1.0.5)"]
docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"]
[[package]]
name = "typing-extensions"
version = "4.6.2"
description = "Backported and Experimental Type Hints for Python 3.7+"
optional = false
python-versions = ">=3.7"
files = [
{file = "typing_extensions-4.6.2-py3-none-any.whl", hash = "sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98"},
{file = "typing_extensions-4.6.2.tar.gz", hash = "sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c"},
]
[[package]]
name = "typing-inspect"
version = "0.9.0"
description = "Runtime inspection utilities for typing module."
optional = false
python-versions = "*"
files = [
{file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
{file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
]
[package.dependencies]
mypy-extensions = ">=0.3.0"
typing-extensions = ">=3.7.4"
[[package]]
name = "urllib3"
version = "2.0.2"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.7"
files = [
{file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"},
{file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8.1"
content-hash = "415a8a46a5b7f86c2bc1acd5b82422cc79c504664665d3dbf11a76c89516ceb3"

29
pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
[tool.poetry]
name = "dspace-statistics-api"
version = "1.4.4-dev"
description = "A simple REST API to expose Solr view and download statistics for items, communities, and collections in a DSpace repository."
authors = ["Alan Orth <aorth@mjanja.ch>"]
license = "GPL-3.0-only"
[tool.poetry.dependencies]
python = "^3.8.1"
gunicorn = "^21.0.0"
falcon = "3.1.3"
psycopg2 = "^2.9.1"
requests = "^2.24.0"
falcon-swagger-ui = {git = "https://github.com/alanorth/falcon-swagger-ui.git", rev="falcon3-update-swagger-ui"}
[tool.poetry.group.dev.dependencies]
black = "^23.0.0"
fixit = "^2.1.0"
flake8 = "^7.0.0"
isort = "^5.9.1"
pytest = "^7.0.0"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
[tool.isort]
profile = "black"
line_length=88

View File

@ -1,4 +1,4 @@
[pytest]
addopts= -rsxX -s -v --strict
addopts= -rsxX -s -v --strict-markers
filterwarnings =
error::UserWarning

9
renovate.json Normal file
View File

@ -0,0 +1,9 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"pip_requirements": {
"enabled": false
}
}

View File

@ -1,37 +1,33 @@
-i https://pypi.org/simple
appdirs==1.4.3
attrs==19.3.0
backcall==0.1.0
black==19.10b0
click==7.0
decorator==4.4.2
entrypoints==0.3
flake8==3.7.9
ipython-genutils==0.2.0
ipython==7.13.0
isort==4.3.21
jedi==0.16.0
mccabe==0.6.1
more-itertools==8.2.0
packaging==20.1
parso==0.6.2
pathspec==0.7.0
pexpect==4.8.0 ; sys_platform != 'win32'
pickleshare==0.7.5
pluggy==0.13.1
prompt-toolkit==3.0.3
ptyprocess==0.6.0
py==1.8.1
pycodestyle==2.5.0
pyflakes==2.1.1
pygments==2.5.2
pyparsing==2.4.6
pytest-clarity==0.3.0a0
pytest==5.3.5
regex==2020.2.20
six==1.14.0
termcolor==1.1.0
toml==0.10.0
traitlets==4.3.3
typed-ast==1.4.1
wcwidth==0.1.8
atomicwrites==1.4.1 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32"
attrs==23.1.0 ; python_version >= "3.8" and python_version < "4.0"
black==22.12.0 ; python_version >= "3.8" and python_version < "4.0"
certifi==2023.5.7 ; python_version >= "3.8" and python_version < "4.0"
charset-normalizer==3.1.0 ; python_version >= "3.8" and python_version < "4.0"
click==8.1.3 ; python_version >= "3.8" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows")
falcon-swagger-ui @ git+https://github.com/alanorth/falcon-swagger-ui.git@falcon3-update-swagger-ui ; python_version >= "3.8" and python_version < "4.0"
falcon==3.1.1 ; python_version >= "3.8" and python_version < "4.0"
flake8==4.0.1 ; python_version >= "3.8" and python_version < "4.0"
gunicorn==20.1.0 ; python_version >= "3.8" and python_version < "4.0"
idna==3.4 ; python_version >= "3.8" and python_version < "4.0"
iniconfig==2.0.0 ; python_version >= "3.8" and python_version < "4.0"
isort==5.12.0 ; python_version >= "3.8" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0"
markupsafe==2.1.2 ; python_version >= "3.8" and python_version < "4.0"
mccabe==0.6.1 ; python_version >= "3.8" and python_version < "4.0"
mypy-extensions==1.0.0 ; python_version >= "3.8" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.8" and python_version < "4.0"
pathspec==0.11.1 ; python_version >= "3.8" and python_version < "4.0"
platformdirs==3.5.1 ; python_version >= "3.8" and python_version < "4.0"
pluggy==1.0.0 ; python_version >= "3.8" and python_version < "4.0"
psycopg2==2.9.6 ; python_version >= "3.8" and python_version < "4.0"
py==1.11.0 ; python_version >= "3.8" and python_version < "4.0"
pycodestyle==2.8.0 ; python_version >= "3.8" and python_version < "4.0"
pyflakes==2.4.0 ; python_version >= "3.8" and python_version < "4.0"
pytest==6.2.5 ; python_version >= "3.8" and python_version < "4.0"
requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0"
setuptools==67.8.0 ; python_version >= "3.8" and python_version < "4.0"
toml==0.10.2 ; python_version >= "3.8" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.8" and python_full_version < "3.11.0a7"
typing-extensions==4.6.2 ; python_version >= "3.8" and python_version < "3.10"
urllib3==2.0.2 ; python_version >= "3.8" and python_version < "4.0"

View File

@ -1,9 +1,12 @@
-i https://pypi.org/simple
certifi==2019.11.28
chardet==3.0.4
falcon==2.0.0
gunicorn==20.0.4
idna==2.9
psycopg2-binary==2.8.4
requests==2.23.0
urllib3==1.25.8
certifi==2023.5.7 ; python_version >= "3.8" and python_version < "4.0"
charset-normalizer==3.1.0 ; python_version >= "3.8" and python_version < "4.0"
falcon-swagger-ui @ git+https://github.com/alanorth/falcon-swagger-ui.git@falcon3-update-swagger-ui ; python_version >= "3.8" and python_version < "4.0"
falcon==3.1.1 ; python_version >= "3.8" and python_version < "4.0"
gunicorn==20.1.0 ; python_version >= "3.8" and python_version < "4.0"
idna==3.4 ; python_version >= "3.8" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0"
markupsafe==2.1.2 ; python_version >= "3.8" and python_version < "4.0"
psycopg2==2.9.6 ; python_version >= "3.8" and python_version < "4.0"
requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0"
setuptools==67.8.0 ; python_version >= "3.8" and python_version < "4.0"
urllib3==2.0.2 ; python_version >= "3.8" and python_version < "4.0"

View File

@ -1,6 +0,0 @@
[isort]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88

File diff suppressed because it is too large Load Diff

View File

@ -1,67 +0,0 @@
from falcon import testing
import json
import pytest
from dspace_statistics_api.app import api
@pytest.fixture
def client():
return testing.TestClient(api)
def test_get_docs(client):
"""Test requesting the documentation at the root."""
response = client.simulate_get("/")
assert isinstance(response.content, bytes)
assert response.status_code == 200
def test_get_item(client):
"""Test requesting a single item."""
response = client.simulate_get("/item/c3910974-c3a5-4053-9dce-104aa7bb1621")
response_doc = json.loads(response.text)
assert isinstance(response_doc["downloads"], int)
assert isinstance(response_doc["id"], str)
assert isinstance(response_doc["views"], int)
assert response.status_code == 200
def test_get_missing_item(client):
"""Test requesting a single non-existing item."""
response = client.simulate_get("/item/c3910974-c3a5-4053-9dce-104aa7bb1620")
assert response.status_code == 404
def test_get_items(client):
"""Test requesting 100 items."""
response = client.simulate_get("/items", query_string="limit=100")
response_doc = json.loads(response.text)
assert isinstance(response_doc["currentPage"], int)
assert isinstance(response_doc["totalPages"], int)
assert isinstance(response_doc["statistics"], list)
assert response.status_code == 200
def test_get_items_invalid_limit(client):
"""Test requesting 100 items with an invalid limit parameter."""
response = client.simulate_get("/items", query_string="limit=101")
assert response.status_code == 400
def test_get_items_invalid_page(client):
"""Test requesting 100 items with an invalid page parameter."""
response = client.simulate_get("/items", query_string="page=-1")
assert response.status_code == 400

View File

@ -0,0 +1,379 @@
# SPDX-License-Identifier: GPL-3.0-only
import json
from unittest.mock import patch
import pytest
from falcon import testing
from dspace_statistics_api.app import app
@pytest.fixture
def client():
return testing.TestClient(app)
def test_get_collection(client):
"""Test requesting a single collection."""
response = client.simulate_get("/collection/8ea4b611-1f59-4d4e-b78d-a9921a72cfe7")
response_doc = json.loads(response.text)
assert isinstance(response_doc["downloads"], int)
assert isinstance(response_doc["id"], str)
assert isinstance(response_doc["views"], int)
assert response.status_code == 200
def test_get_missing_collection(client):
"""Test requesting a single non-existing collection."""
response = client.simulate_get("/collection/508abe0a-689f-402e-885d-2f6b02e7a39c")
assert response.status_code == 404
def test_get_collections(client):
"""Test requesting 100 collections."""
response = client.simulate_get("/collections", query_string="limit=100")
response_doc = json.loads(response.text)
assert isinstance(response_doc["currentPage"], int)
assert isinstance(response_doc["totalPages"], int)
assert isinstance(response_doc["statistics"], list)
assert response.status_code == 200
def test_get_collections_invalid_limit(client):
"""Test requesting 100 collections with an invalid limit parameter."""
response = client.simulate_get("/collections", query_string="limit=101")
assert response.status_code == 400
def test_get_collections_invalid_page(client):
"""Test requesting 100 collections with an invalid page parameter."""
response = client.simulate_get("/collections", query_string="page=-1")
assert response.status_code == 400
@pytest.mark.xfail
def test_post_collections_valid_dateFrom(client):
"""Test POSTing a request to /collections with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_valid_dateFrom_mocked(client):
"""Mock test POSTing a request to /collections with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
get_views_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 21,
"260548c8-fda4-4dc8-a979-03495753cdd5": 0,
}
get_downloads_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 575,
"260548c8-fda4-4dc8-a979-03495753cdd5": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_invalid_dateFrom(client):
"""Test POSTing a request to /collections with an invalid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_collections_valid_dateTo(client):
"""Test POSTing a request to /collections with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_valid_dateTo_mocked(client):
"""Mock test POSTing a request to /collections with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
get_views_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 21,
"260548c8-fda4-4dc8-a979-03495753cdd5": 0,
}
get_downloads_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 575,
"260548c8-fda4-4dc8-a979-03495753cdd5": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_invalid_dateTo(client):
"""Test POSTing a request to /collections with an invalid dateTo parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_collections_valid_limit(client):
"""Test POSTing a request to /collections with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_collections_valid_limit_mocked(client):
"""Mock test POSTing a request to /collections with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
get_views_return_value = {"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 21}
get_downloads_return_value = {"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 575}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_collections_invalid_limit(client):
"""Test POSTing a request to /collections with an invalid limit parameter in the request body."""
request_body = {
"limit": -1,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_collections_valid_page(client):
"""Test POSTing a request to /collections with a valid page parameter in the request body."""
request_body = {
"page": 0,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert response.json["totalPages"] == 1
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_valid_page_mocked(client):
"""Mock test POSTing a request to /collections with a valid page parameter in the request body."""
request_body = {
"page": 0,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
get_views_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 21,
"260548c8-fda4-4dc8-a979-03495753cdd5": 0,
}
get_downloads_return_value = {
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7": 575,
"260548c8-fda4-4dc8-a979-03495753cdd5": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_collections_invalid_page(client):
"""Test POSTing a request to /collections with an invalid page parameter in the request body."""
request_body = {
"page": -1,
"collections": [
"8ea4b611-1f59-4d4e-b78d-a9921a72cfe7",
"260548c8-fda4-4dc8-a979-03495753cdd5",
],
}
response = client.simulate_post("/collections", json=request_body)
assert response.status_code == 400
# vim: set sw=4 ts=4 expandtab:

View File

@ -0,0 +1,379 @@
# SPDX-License-Identifier: GPL-3.0-only
import json
from unittest.mock import patch
import pytest
from falcon import testing
from dspace_statistics_api.app import app
@pytest.fixture
def client():
return testing.TestClient(app)
def test_get_community(client):
"""Test requesting a single community."""
response = client.simulate_get("/community/bde7139c-d321-46bb-aef6-ae70799e5edb")
response_doc = json.loads(response.text)
assert isinstance(response_doc["downloads"], int)
assert isinstance(response_doc["id"], str)
assert isinstance(response_doc["views"], int)
assert response.status_code == 200
def test_get_missing_community(client):
"""Test requesting a single non-existing community."""
response = client.simulate_get("/item/dec6bfc6-efeb-4f74-8436-79fa80bb5c21")
assert response.status_code == 404
def test_get_communities(client):
"""Test requesting 100 communities."""
response = client.simulate_get("/communities", query_string="limit=100")
response_doc = json.loads(response.text)
assert isinstance(response_doc["currentPage"], int)
assert isinstance(response_doc["totalPages"], int)
assert isinstance(response_doc["statistics"], list)
assert response.status_code == 200
def test_get_communities_invalid_limit(client):
"""Test requesting 100 communities with an invalid limit parameter."""
response = client.simulate_get("/communities", query_string="limit=101")
assert response.status_code == 400
def test_get_communities_invalid_page(client):
"""Test requesting 100 communities with an invalid page parameter."""
response = client.simulate_get("/communities", query_string="page=-1")
assert response.status_code == 400
@pytest.mark.xfail
def test_post_communities_valid_dateFrom(client):
"""Test POSTing a request to /communities with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_valid_dateFrom_mocked(client):
"""Mock test POSTing a request to /communities with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
get_views_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 309,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 0,
}
get_downloads_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 400,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 290,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_invalid_dateFrom(client):
"""Test POSTing a request to /communities with an invalid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_communities_valid_dateTo(client):
"""Test POSTing a request to /communities with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_valid_dateTo_mocked(client):
"""Mock test POSTing a request to /communities with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
get_views_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 21,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 0,
}
get_downloads_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 575,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_invalid_dateTo(client):
"""Test POSTing a request to /communities with an invalid dateTo parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_communities_valid_limit(client):
"""Test POSTing a request to /communities with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_communities_valid_limit_mocked(client):
"""Mock test POSTing a request to /communities with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
get_views_return_value = {"bde7139c-d321-46bb-aef6-ae70799e5edb": 200}
get_downloads_return_value = {"bde7139c-d321-46bb-aef6-ae70799e5edb": 309}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_communities_invalid_limit(client):
"""Test POSTing a request to /communities with an invalid limit parameter in the request body."""
request_body = {
"limit": -1,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_communities_valid_page(client):
"""Test POSTing a request to /communities with a valid page parameter in the request body."""
request_body = {
"page": 0,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert response.json["totalPages"] == 1
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_valid_page_mocked(client):
"""Mock test POSTing a request to communities with a valid page parameter in the request body."""
request_body = {
"page": 0,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
get_views_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 21,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 0,
}
get_downloads_return_value = {
"bde7139c-d321-46bb-aef6-ae70799e5edb": 575,
"2a920a61-b08a-4642-8e5d-2639c6702b1f": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_communities_invalid_page(client):
"""Test POSTing a request to /communities with an invalid page parameter in the request body."""
request_body = {
"page": -1,
"communities": [
"bde7139c-d321-46bb-aef6-ae70799e5edb",
"2a920a61-b08a-4642-8e5d-2639c6702b1f",
],
}
response = client.simulate_post("/communities", json=request_body)
assert response.status_code == 400
# vim: set sw=4 ts=4 expandtab:

50
tests/test_api_docs.py Normal file
View File

@ -0,0 +1,50 @@
# SPDX-License-Identifier: GPL-3.0-only
import pytest
from falcon import testing
from dspace_statistics_api.app import app
@pytest.fixture
def client():
return testing.TestClient(app)
def test_get_docs(client):
"""Test requesting the documentation at the root."""
response = client.simulate_get("/")
assert isinstance(response.content, bytes)
assert response.status_code == 200
def test_get_openapi_json(client):
"""Test requesting the OpenAPI JSON schema."""
response = client.simulate_get("/docs/openapi.json")
assert isinstance(response.content, bytes)
assert response.status_code == 200
def test_get_swagger_ui(client):
"""Test requesting the Swagger UI."""
response = client.simulate_get("/swagger")
assert isinstance(response.content, bytes)
assert response.status_code == 200
def test_get_status(client):
"""Test requesting the status page."""
response = client.simulate_get("/status")
assert isinstance(response.content, bytes)
assert response.status_code == 200
# vim: set sw=4 ts=4 expandtab:

379
tests/test_api_items.py Normal file
View File

@ -0,0 +1,379 @@
# SPDX-License-Identifier: GPL-3.0-only
import json
from unittest.mock import patch
import pytest
from falcon import testing
from dspace_statistics_api.app import app
@pytest.fixture
def client():
return testing.TestClient(app)
def test_get_item(client):
"""Test requesting a single item."""
response = client.simulate_get("/item/fd8a46d5-1480-4e69-b187-cd3db96d8e4d")
response_doc = json.loads(response.text)
assert isinstance(response_doc["downloads"], int)
assert isinstance(response_doc["id"], str)
assert isinstance(response_doc["views"], int)
assert response.status_code == 200
def test_get_missing_item(client):
"""Test requesting a single non-existing item."""
response = client.simulate_get("/item/c3910974-c3a5-4053-9dce-104aa7bb1620")
assert response.status_code == 404
def test_get_items(client):
"""Test requesting 100 items."""
response = client.simulate_get("/items", query_string="limit=100")
response_doc = json.loads(response.text)
assert isinstance(response_doc["currentPage"], int)
assert isinstance(response_doc["totalPages"], int)
assert isinstance(response_doc["statistics"], list)
assert response.status_code == 200
def test_get_items_invalid_limit(client):
"""Test requesting 100 items with an invalid limit parameter."""
response = client.simulate_get("/items", query_string="limit=101")
assert response.status_code == 400
def test_get_items_invalid_page(client):
"""Test requesting 100 items with an invalid page parameter."""
response = client.simulate_get("/items", query_string="page=-1")
assert response.status_code == 400
@pytest.mark.xfail
def test_post_items_valid_dateFrom(client):
"""Test POSTing a request to /items with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_valid_dateFrom_mocked(client):
"""Mock test POSTing a request to /items with a valid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00Z",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
get_views_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 21,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 0,
}
get_downloads_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 575,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_invalid_dateFrom(client):
"""Test POSTing a request to /items with an invalid dateFrom parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_items_valid_dateTo(client):
"""Test POSTing a request to /items with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_valid_dateTo_mocked(client):
"""Mock test POSTing a request to /items with a valid dateTo parameter in the request body."""
request_body = {
"dateTo": "2020-01-01T00:00:00Z",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
get_views_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 21,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 0,
}
get_downloads_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 575,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_invalid_dateTo(client):
"""Test POSTing a request to /items with an invalid dateTo parameter in the request body."""
request_body = {
"dateFrom": "2020-01-01T00:00:00",
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_items_valid_limit(client):
"""Test POSTing a request to /items with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_items_valid_limit_mocked(client):
"""Mock test POSTing a request to /items with a valid limit parameter in the request body."""
request_body = {
"limit": 1,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
get_views_return_value = {"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 21}
get_downloads_return_value = {"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 575}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 1
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 1
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
def test_post_items_invalid_limit(client):
"""Test POSTing a request to /items with an invalid limit parameter in the request body."""
request_body = {
"limit": -1,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 400
@pytest.mark.xfail
def test_post_items_valid_page(client):
"""Test POSTing a request to /items with a valid page parameter in the request body."""
request_body = {
"page": 0,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert response.json["totalPages"] == 1
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_valid_page_mocked(client):
"""Mock test POSTing a request to /items with a valid page parameter in the request body."""
request_body = {
"page": 0,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
get_views_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 21,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 0,
}
get_downloads_return_value = {
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d": 575,
"e53a2eab-1e31-448d-907b-3656ca4e86c1": 899,
}
with patch(
"dspace_statistics_api.app.get_views", return_value=get_views_return_value
):
with patch(
"dspace_statistics_api.app.get_downloads",
return_value=get_downloads_return_value,
):
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 200
assert response.json["limit"] == 100
assert response.json["currentPage"] == 0
assert isinstance(response.json["totalPages"], int)
assert len(response.json["statistics"]) == 2
assert isinstance(response.json["statistics"][0]["views"], int)
assert isinstance(response.json["statistics"][0]["downloads"], int)
assert isinstance(response.json["statistics"][1]["views"], int)
assert isinstance(response.json["statistics"][1]["downloads"], int)
def test_post_items_invalid_page(client):
"""Test POSTing a request to /items with an invalid page parameter in the request body."""
request_body = {
"page": -1,
"items": [
"fd8a46d5-1480-4e69-b187-cd3db96d8e4d",
"e53a2eab-1e31-448d-907b-3656ca4e86c1",
],
}
response = client.simulate_post("/items", json=request_body)
assert response.status_code == 400
# vim: set sw=4 ts=4 expandtab: