mirror of
https://github.com/ilri/dspace-statistics-api.git
synced 2025-05-10 15:16:02 +02:00
Compare commits
490 Commits
Author | SHA1 | Date | |
---|---|---|---|
7b8ff7c02f
|
|||
6313177b4b
|
|||
fb5df25ef5
|
|||
dab96ce77a
|
|||
ff7641d455
|
|||
bfe12da212
|
|||
68cb3905d1
|
|||
41d831d29f
|
|||
ce0a2d9213
|
|||
8c1a8bde54
|
|||
ffe6030a9e
|
|||
e90cfe9e69
|
|||
719d86dd30 | |||
d3982df636 | |||
656661de8f
|
|||
1e251a259a
|
|||
eca0d28849
|
|||
718bfab897
|
|||
8196d28e88
|
|||
f3421e595c
|
|||
6cf8ca0245
|
|||
14c6e5f8dc
|
|||
4c32aeb915
|
|||
34a1a08893
|
|||
c47bb2aba7
|
|||
a7fd70bf10
|
|||
45dfe7851f | |||
c1cd0a0351 | |||
1912363899 | |||
cd3c024a77 | |||
e96c79bf2c | |||
d6330c7bd4 | |||
8c7a5c4047 | |||
a31c592fab | |||
c7b179f1b5 | |||
77c166c024 | |||
7680b0f440
|
|||
e70a7a9675
|
|||
24f90df13e
|
|||
780f2c1723
|
|||
53b58d4116 | |||
19a6d2cea6 | |||
6c2bcda16f | |||
e4d9545b02 | |||
1f507d3074 | |||
82771d7b0c | |||
5ff3323f88 | |||
c7a871c2f1 | |||
b948283d40 | |||
124a05dcaf | |||
a2daf96fec | |||
8634d53fa6 | |||
e2bfcef573 | |||
d64c4b8cbc | |||
3d91366412 | |||
c3a4e2260b | |||
10519997ac | |||
4d7e9e9401 | |||
fe9f98bcc0
|
|||
70f0d66c6e
|
|||
913596c61d
|
|||
7cd762a5a2 | |||
3811be18ef | |||
a52818271c | |||
b643f60dd7 | |||
7cec9a9545 | |||
a9302506b6 | |||
b980602a03
|
|||
a4b4843036
|
|||
7e334f6de8
|
|||
770f676fb5
|
|||
6d5e3c350d
|
|||
531136183b
|
|||
1a3d0350a5
|
|||
25c4f05f16
|
|||
9fba8d1b81
|
|||
568ced0f20
|
|||
9cd93c9034 | |||
83a2625987 | |||
f591ed7162 | |||
bb0f267941
|
|||
0720605b6a
|
|||
bcb97d025c
|
|||
0ff8490275
|
|||
0a8ac60ade
|
|||
37527c21be
|
|||
eb660f8085
|
|||
e7d780f511
|
|||
c3b9a541b7
|
|||
1a1a14a25f
|
|||
c09fc789e8
|
|||
134a4f1595
|
|||
12ebd1aed5
|
|||
e5f3201b65
|
|||
c1ce4fe233
|
|||
b2eb1878a5
|
|||
a0213c1c97
|
|||
cd03ca2b36
|
|||
c48e6a79c7
|
|||
a2e1695ecc
|
|||
b683bf211c
|
|||
3ab48743d6
|
|||
88173eaae9
|
|||
f557d33f36
|
|||
ffc4ff4a5c
|
|||
7551b34632
|
|||
5e71ec10eb
|
|||
f80d360cf9
|
|||
e70b59ecfe
|
|||
4d0828b6c0
|
|||
dabc4c0259
|
|||
4fd8af07c3
|
|||
4c5326a176
|
|||
3b1ccafab4
|
|||
58b5ae82d3
|
|||
562aaeef7d
|
|||
5cdba6acb1
|
|||
dd0937179c
|
|||
f0c6c004db
|
|||
6843f0a8ac
|
|||
f5fcfcc05a
|
|||
e8ac74b6d1
|
|||
14fc14daee
|
|||
871aae537a
|
|||
2fada6c6ff
|
|||
ef0991e352
|
|||
4502d6053c
|
|||
a524068cf6
|
|||
964d5dff06
|
|||
a9252d1771
|
|||
a63687d516
|
|||
73dc3a292e
|
|||
1e742bad41
|
|||
164008981e
|
|||
dd1769b954
|
|||
b009820fb4
|
|||
9830295978
|
|||
c93a4d7455
|
|||
2f8e4f8a0a | |||
0650c5985e
|
|||
d814f1c4f0
|
|||
00f30591c4
|
|||
acfe87b91a
|
|||
bc6d84dda2 | |||
889fb2f74a
|
|||
c42cd7a818
|
|||
f8bba59d66
|
|||
b8cb752a29
|
|||
09496aa2b5
|
|||
ff5dc7506d
|
|||
80a11ead97
|
|||
a282c95933
|
|||
fd7cc36306
|
|||
a20ff09570
|
|||
fdc0e73088
|
|||
b15afc9f39
|
|||
2bc18ef719
|
|||
49751b53f0
|
|||
d1c177e146
|
|||
33dc210452
|
|||
282d5f644a
|
|||
05e0e8bdca
|
|||
2567bb8604
|
|||
4af3c656a3
|
|||
4f8cd1097b
|
|||
a02211fd60
|
|||
fc814593c7
|
|||
7de1084f60
|
|||
6b78e82fe9
|
|||
4004515967
|
|||
d1229c2387
|
|||
be83514de1
|
|||
70b2ba83ba
|
|||
893039bc6a
|
|||
a4628dde4e
|
|||
68418ea053
|
|||
6bbee7919e
|
|||
8f0061ce29
|
|||
4b1398c67f
|
|||
a9d2a6d9be
|
|||
a35ecf2394
|
|||
3e271c7852
|
|||
d7ba14c590
|
|||
ab82e90773
|
|||
8a1244d2d0
|
|||
04f0756c7f
|
|||
830e4415f5
|
|||
47b4eb3df7
|
|||
3339bf8d9c
|
|||
fba6f1ead1 | |||
20c8ba0cf8 | |||
b486f51dd7 | |||
787eec20ea
|
|||
9e6fcf279b
|
|||
4dbf734a4b
|
|||
a0d0a47150
|
|||
01e9756cf2
|
|||
b2b4eb2939
|
|||
4bbbaa4af3
|
|||
7e4d5f4b13
|
|||
428172854d
|
|||
2707cb37d5
|
|||
2407aeec70
|
|||
f3a0e3a671
|
|||
4590fc8708
|
|||
8b924cf450
|
|||
ea24c73a6a
|
|||
cd98d33615
|
|||
9d112266ca
|
|||
2b067050ff
|
|||
dc683f2d1c
|
|||
f60f529bd7
|
|||
7db8458201
|
|||
707f878b94 | |||
930250352a
|
|||
e27f30ba4d
|
|||
28d1917038
|
|||
fc6a9c2ad1
|
|||
3125e96a16
|
|||
66143ff00f
|
|||
2d15f12be9
|
|||
9218039e61
|
|||
88a8db6c78
|
|||
3995eba0a7
|
|||
810508d038
|
|||
ecafab57cb
|
|||
9c9431b58c
|
|||
2d6520fc97
|
|||
79a393d33f
|
|||
149f6c418f
|
|||
ca1582a8b6
|
|||
1904c243a4
|
|||
0baa07f70a
|
|||
59214ffcb6
|
|||
549b8bf1a7
|
|||
899a79b2e7
|
|||
4c59469055
|
|||
4e9064329d
|
|||
4958d5d2e9
|
|||
923ed0a434
|
|||
5acd927210
|
|||
630fa0d5fb
|
|||
58d2b8d4ed
|
|||
e6572d9469
|
|||
85fca81611
|
|||
c81a8d03d7
|
|||
2923a3b325
|
|||
d4518d62ad
|
|||
3a98de78e3
|
|||
b26439daf3
|
|||
9e898ba54f
|
|||
716d65030c
|
|||
5a53b57b3b
|
|||
3ceb9a6eb0
|
|||
946f0749e2
|
|||
b06651d1ec
|
|||
a0ee181361
|
|||
f58c209609
|
|||
6dbff1e78f
|
|||
731226ec15
|
|||
2201d3df4e
|
|||
2c0436f845
|
|||
f1e939481b
|
|||
4d8026a3d0
|
|||
de1f462ad2
|
|||
8a6bbfd527
|
|||
73c71fa8a0
|
|||
7a5e14716d
|
|||
21b500b4f7
|
|||
495386856b
|
|||
8e87f80e9a
|
|||
c4bf8bf698
|
|||
6ff95bb5f2
|
|||
0c8fb21f80
|
|||
b359c2466f
|
|||
0eaed3e8c4
|
|||
70e96214c8
|
|||
cab9f16dbc
|
|||
bd49e1d1f6
|
|||
144ed9a7c4
|
|||
48eef8c8e3
|
|||
fa9325e8a3 | |||
998e833470 | |||
dd8252601f | |||
9a9555853f | |||
385e92cc5e | |||
b0e6481961 | |||
f96a903be3 | |||
fcf8fa4c29 | |||
5dd50ff998 | |||
6704e7375f | |||
37630d8dac | |||
0ef071a91d | |||
9e7dd28156 | |||
60e6ea57b1 | |||
5955868b9a | |||
250fd8164f | |||
82be1a4d00 | |||
0615064e3d | |||
76be1b749a | |||
92146fe426
|
|||
440b2f2dfa
|
|||
67bc30ead0
|
|||
142959acdb
|
|||
322f5a8db8
|
|||
90dcaa6ec6
|
|||
9aca827d69
|
|||
1b394ec50e
|
|||
3e9753b600
|
|||
cb3c3d37fa
|
|||
4ff1fd4a22
|
|||
d2fe420a9a
|
|||
3197b79578
|
|||
eeb8e6bba1
|
|||
3540ce328b
|
|||
520e04f9be
|
|||
8a46a64cfc
|
|||
b8442f8cce
|
|||
95f7871cc1
|
|||
3bc07027e5
|
|||
afcc445855
|
|||
494548c691
|
|||
feb60b6adf
|
|||
1541ae3e3b
|
|||
1aedc0ca29
|
|||
a648183f35
|
|||
b8f379e7fa
|
|||
78f9949ecb
|
|||
af80c4b447
|
|||
edd9e90f59
|
|||
1806d50a51
|
|||
a459e66fd9
|
|||
5a3b392a1d | |||
9dcda114c6 | |||
2b8aba5835
|
|||
9eb30a98e3
|
|||
622e9a86f1
|
|||
2acd08e0ab
|
|||
f75bcf292c
|
|||
8f46ceb8d8
|
|||
18e1e1a227
|
|||
fd46041698
|
|||
4ce7231ece
|
|||
60689d9014
|
|||
7bca32189a
|
|||
94c5d91d3c
|
|||
a640f734c8
|
|||
d56a3420f7
|
|||
7add0d6164
|
|||
c86bec4d8f
|
|||
5429fe5cc8
|
|||
f8a4cfd3da | |||
be94c94433 | |||
ba49b78a25
|
|||
842f80036f
|
|||
f738b8029b | |||
d08c43f3d5 | |||
819f8e6b0d | |||
c79e50a364
|
|||
71006d8bbf
|
|||
b7d723ef7c
|
|||
914ec52fbb
|
|||
5524066656
|
|||
043d897cef
|
|||
bd28353cda
|
|||
e23d66c2a2
|
|||
40e284dac0
|
|||
934fa9db9b
|
|||
1fabb72b58
|
|||
c7f95f0b60
|
|||
c95a98dd2d
|
|||
3f70f94a10
|
|||
9b8ad9defd | |||
d69ab20220
|
|||
378f56ddc2
|
|||
5a2a7d684c
|
|||
18276e910f
|
|||
8de8c2765f | |||
11a1755e59
|
|||
a835b0fdc5
|
|||
a88600c92b
|
|||
019d9242c9 | |||
f4d7312a3f
|
|||
9c46cfc7e2
|
|||
c1c2e319ac
|
|||
0895b4f469
|
|||
dcfef06a65
|
|||
13736d6359
|
|||
4fc64edeb8 | |||
2a8901dc4f
|
|||
e25c974796
|
|||
ffc62e9ee6
|
|||
556c5ae088
|
|||
d94134f80a
|
|||
586231eb2d
|
|||
766b77a3b6
|
|||
1959e8154e
|
|||
d40b2f0b2e | |||
061d0a8f5f | |||
e57660ff88 | |||
5c8756bede | |||
bae9fb80e4 | |||
8a65d99e08
|
|||
d479b7dc6c
|
|||
40aac8bf89 | |||
53ba6f2936
|
|||
140cc4cb07
|
|||
d5d2d2149b
|
|||
4c51d12eb4
|
|||
a6ce44e852 | |||
f6e866a589
|
|||
eb5c187d41
|
|||
b06c82bb16
|
|||
2f342be948
|
|||
e39f2b260c | |||
60ad474b88
|
|||
888f85d19e
|
|||
df7de93964
|
|||
7218631cc4
|
|||
085e525b2f
|
|||
e1580df12f
|
|||
be18779ff9
|
|||
60cfd8f23b
|
|||
87fd117d77
|
|||
f262ebdca2
|
|||
64d7f1a3b2
|
|||
a238a727d2
|
|||
cc5ce3ab98 | |||
70dfcb93c5
|
|||
69bcd1b5e4
|
|||
5f3bd61998
|
|||
e54dd8888f
|
|||
2ba09f8693
|
|||
a468a87a5a
|
|||
6a30b6550d
|
|||
18f013bfa0
|
|||
78900b5d85
|
|||
eb08832bf8
|
|||
c2ec780ad9
|
|||
df8ebc8bf1
|
|||
0d4be5f4c8
|
|||
30dc7f1939
|
|||
77194707fd
|
|||
10c1f8bdcc
|
|||
da74943da2
|
|||
fc8348ab29
|
|||
15c3299b99
|
|||
d36be5ee50 | |||
2f45d27554 | |||
b8356f7a87 | |||
2136dc79ce | |||
ed60120cef | |||
c027f01b48 | |||
754663f062
|
|||
507699e58a
|
|||
a016916995
|
|||
6fd2827a7c
|
|||
62142eb79e
|
|||
fda0321942
|
|||
963aa245c8
|
|||
568ff2eebb
|
|||
deecb8a10b
|
|||
12f45d7c08
|
|||
f65089f9ce
|
|||
1db5cf1c29
|
|||
e581c4b1aa
|
|||
e8d356c9ca
|
|||
34a9b8d629
|
|||
41e3d66a0e
|
|||
9b2a6137b4
|
|||
600b986f99
|
|||
49a7790794
|
|||
f2deba627c
|
|||
9323513794
|
|||
daf15610f2
|
|||
4ede966dbb
|
|||
3580473a6d
|
|||
071c24535f
|
|||
4291aecac4
|
|||
46bf537e88
|
|||
eaca5354d3
|
|||
4600288ee4
|
|||
8179563378
|
|||
b14c3eef4d
|
|||
71a789b13f
|
|||
c68ddacaa4
|
|||
9c9e79769e
|
|||
2ad5ade556
|
|||
7412a09670
|
|||
bb744a00b8
|
56
.github/workflows/python-app.yml
vendored
Normal file
56
.github/workflows/python-app.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
# 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-latest
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:17-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 uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: 'latest'
|
||||
- run: uv sync
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
uv 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
|
||||
uv 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: |
|
||||
uv run pytest
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPASSWORD: dspacestatistics
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
__pycache__
|
||||
venv
|
||||
*.egg-info
|
||||
|
@ -1,9 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
|
||||
# vim: ts=2 sw=2 et
|
206
CHANGELOG.md
206
CHANGELOG.md
@ -4,9 +4,213 @@ 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
|
||||
|
||||
## 1.4.5 - 2025-04-02
|
||||
### Changed
|
||||
- Use uv instead of poetry to manage the project and dependencies
|
||||
- Update recommended Python version to 3.9+
|
||||
|
||||
### Updated
|
||||
- Falcon 4.0.x
|
||||
- psycopg2 to psycopg (version 3.2.6)
|
||||
|
||||
## 1.4.4 - 2024-09-11
|
||||
### Changed
|
||||
- Update recommended Python version to 3.8+
|
||||
- Use PostgreSQL 15 in CI
|
||||
- Use Python 3.12 in CI
|
||||
|
||||
### Updated
|
||||
- Falcon 3.1.3, a minor change for us, but good to be using a current upstream
|
||||
version
|
||||
|
||||
### Removed
|
||||
- Drone CI
|
||||
|
||||
## 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
|
||||
- Remove Python 3.5 from TravisCI because black requires Python >= 3.6
|
||||
- Adapt API for DSpace 6+ UUIDs
|
||||
- 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
|
||||
### Added
|
||||
- Configuration for automatic sorting of imports with isort
|
||||
- Configuration for automatic code formatting with black
|
||||
|
||||
### Updated
|
||||
- Run pipenv update, bringing psycopg2 2.8.4, requests 2.22.0, pytest 5.3.1,
|
||||
and gunicorn 20.0.4
|
||||
|
||||
### Changed
|
||||
- Use Ubuntu 18.04 "Bionic" for TravisCI builds
|
||||
- Use Python 3.8.0 for pipenv
|
||||
- Minor syntax issues highlighted by flake8
|
||||
|
||||
## [1.1.0] - 2019-05-05
|
||||
### Updated
|
||||
- Falcon 2.0.0 (@alanorth)
|
||||
|
||||
## [1.0.0] - 2019-04-15
|
||||
### Added
|
||||
- Build configuration for build.sr.ht
|
||||
|
||||
### Updated
|
||||
- Run pipenv update, bringing pytest version 4.4.0, psycopg-binary 2.8.2, etc
|
||||
- sr.ht and TravisCI configuration to disable emojis and animation to keep logs clean
|
||||
|
||||
### Changed
|
||||
- Use vanilla requests library instead of SolrClient
|
||||
- Use one-based paging in indexer output (for human readability)
|
||||
|
||||
## [0.9.0] - 2019-01-22
|
||||
### Updated
|
||||
- pytest version 4.0.0
|
||||
- Fix indexing of sharded statistics cores (#10)
|
||||
- Handle case of missing views/downloads gracefully
|
||||
|
||||
## [0.8.1] - 2018-11-14
|
||||
### Changed
|
||||
- README.md to recommend using vanilla Python virtual environments and pip instead of pipenv
|
||||
- Regenerate pipenv environment to capture only direct dependencies
|
||||
|
||||
### Added
|
||||
- `requirements-dev.txt` for installing development packages with pip
|
||||
|
||||
## [0.8.0] - 2018-11-11
|
||||
### Changed
|
||||
- Properly handle database connection errors
|
||||
|
||||
### Added
|
||||
- API tests with pytest
|
||||
|
||||
## [0.7.0] - 2018-11-07
|
||||
### Added
|
||||
- Ability to configure PostgreSQL database port with DATABASE_PORT environment variable (defaults to 5432)
|
||||
- Hound CI configuration to validate pull requests against PEP 8 code style with Flake8
|
||||
- Configuration for [pipenv](https://pipenv.readthedocs.io/en/latest/)
|
||||
|
||||
### Changed
|
||||
- Use a database management class with Python context management to automatically open/close connections and cursors
|
||||
|
||||
### Changed
|
||||
- Validate code against PEP 8 style guide with Flake8
|
||||
|
||||
## [0.6.1] - 2018-10-31
|
||||
### Added
|
||||
- API documentation at root path (/)
|
||||
|
||||
## [0.6.0] - 2018-10-31
|
||||
### Changed
|
||||
- Refactor project structure (note breaking changes to API and indexing invocation, see contrib and README.md)
|
||||
|
||||
## [0.5.2] - 2018-10-28
|
||||
### Changed
|
||||
- Update library versions in requirements.txt
|
||||
|
||||
## [0.5.1] - 2018-10-24
|
||||
### Changed
|
||||
- Use Python's native json instead of ujson
|
||||
|
||||
## [0.5.0] - 2018-10-24
|
||||
### Added
|
||||
- Example nginx configuration to README.md
|
||||
|
||||
### Changed
|
||||
- Don't initialize Solr connection in API
|
||||
|
||||
## [0.4.3] - 2018-10-17
|
||||
### Changed
|
||||
- Use pip install as script for Travis CI
|
||||
|
||||
### Improved
|
||||
- Documentation for deployment and testing
|
||||
|
||||
## [0.4.2] - 2018-10-04
|
||||
### Changed
|
||||
- README.md introduction and requirements
|
||||
- Use ujson instead of json
|
||||
- Iterate directly on SQL cursor in `/items` route
|
||||
|
||||
### Fixed
|
||||
- Logic error in SQL for item views
|
||||
|
||||
## [0.4.1] - 2018-09-26
|
||||
### Changed
|
||||
- Use execute_values() to batch insert records to PostgreSQL
|
||||
- Use `execute_values()` to batch insert records to PostgreSQL
|
||||
|
||||
## [0.4.0] - 2018-09-25
|
||||
### Fixed
|
||||
|
131
README.md
131
README.md
@ -1,30 +1,133 @@
|
||||
# DSpace Statistics API
|
||||
A quick and dirty REST API to expose Solr view and download statistics for items in a DSpace repository.
|
||||
<h1 align="center">DSpace Statistics API</h1>
|
||||
|
||||
Written and tested in Python 3.5, 3.6, and 3.7. Requires PostgreSQL version 9.5 or greater for [`UPSERT` support](https://wiki.postgresql.org/wiki/UPSERT).
|
||||
<p align="center">
|
||||
<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. As of DSpace 7, the [REST API](https://wiki.lyrasis.org/display/DSDOC7x/REST+API) exposes _some_ statistics about DSpace objects, but does not provide a way to get them for all items, and lacks some flexibility.
|
||||
|
||||
- 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:
|
||||
|
||||
*Orth, A. 2018. DSpace statistics API. Nairobi, Kenya: ILRI. https://hdl.handle.net/10568/99143.*
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- PostgreSQL version 9.5+ (due to [`UPSERT` support](https://wiki.postgresql.org/wiki/UPSERT))
|
||||
- DSpace with [Solr usage statistics enabled](https://wiki.lyrasis.org/display/DSDOC5x/SOLR+Statistics) (tested with 5.8+, 6.3, and 7.6)
|
||||
|
||||
## Installation
|
||||
Create a virtual environment and run it:
|
||||
Create a Python virtual environment and install the dependencies:
|
||||
|
||||
$ python -m venv venv
|
||||
$ . venv/bin/activate
|
||||
$ python3 -m venv venv
|
||||
$ source venv/bin/activate
|
||||
$ pip install -r requirements.txt
|
||||
$ gunicorn app:api
|
||||
|
||||
## Running
|
||||
|
||||
Set up the environment variables for Solr and PostgreSQL:
|
||||
|
||||
$ export SOLR_SERVER=http://localhost:8983/solr
|
||||
$ export DATABASE_NAME=dspacestatistics
|
||||
$ export DATABASE_USER=dspacestatistics
|
||||
$ export DATABASE_PASS=dspacestatistics
|
||||
$ export DATABASE_HOST=localhost
|
||||
|
||||
Index the Solr statistics core to populate the PostgreSQL database:
|
||||
|
||||
$ python -m dspace_statistics_api.indexer
|
||||
|
||||
Run the REST API:
|
||||
|
||||
$ gunicorn dspace_statistics_api.app
|
||||
|
||||
Test to see if there are any statistics:
|
||||
|
||||
$ curl 'http://localhost:8000/items?limit=1'
|
||||
|
||||
## Testing
|
||||
Install development packages using pip:
|
||||
|
||||
$ pip install -r requirements-dev.txt
|
||||
|
||||
Run tests:
|
||||
|
||||
$ pytest
|
||||
|
||||
## Deployment
|
||||
There are example systemd service and timer units in the `contrib` directory. The API service listens on localhost by default so you will need to expose it publicly using a web server like nginx.
|
||||
|
||||
An example nginx configuration is:
|
||||
|
||||
```
|
||||
server {
|
||||
#...
|
||||
|
||||
location ~ /rest/statistics/?(.*) {
|
||||
access_log /var/log/nginx/statistics.log;
|
||||
proxy_pass http://statistics_api/$1$is_args$args;
|
||||
}
|
||||
}
|
||||
|
||||
upstream statistics_api {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
```
|
||||
|
||||
This would expose the API at `/rest/statistics`.
|
||||
|
||||
## Using the API
|
||||
The API exposes the following endpoints:
|
||||
|
||||
- GET `/items` — return views and downloads for all items that Solr knows about¹. Accepts `limit` and `page` query parameters for pagination of results.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
¹ We are querying the Solr statistics core, which technically only knows about items that have either views or downloads.
|
||||
The id is the *internal* UUID for an item, community, or collection. You can get these from the standard DSpace REST API.
|
||||
|
||||
## Todo
|
||||
¹ 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.
|
||||
|
||||
² 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
|
||||
|
||||
- Add API documentation
|
||||
- Close up DB connection when gunicorn shuts down gracefully
|
||||
- Better logging
|
||||
- Tests
|
||||
- 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`?
|
||||
- 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).
|
||||
|
||||
The license allows you to use and modify the work for personal and commercial purposes, but if you distribute the work you must provide users with a means to access the source code for the version you are distributing. Read more about the [GPLv3 at TL;DR Legal](https://tldrlegal.com/license/gnu-general-public-license-v3-(gpl-3)).
|
||||
|
72
app.py
72
app.py
@ -1,72 +0,0 @@
|
||||
from database import database_connection
|
||||
import falcon
|
||||
from solr import solr_connection
|
||||
|
||||
db = database_connection()
|
||||
db.set_session(readonly=True)
|
||||
solr = solr_connection()
|
||||
|
||||
class AllItemsResource:
|
||||
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=0, max=100) or 100
|
||||
page = req.get_param_as_int("page", min=0) or 0
|
||||
offset = limit * page
|
||||
|
||||
cursor = db.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 statistics, ordered by id, and use limit and offset to page through results
|
||||
cursor.execute('SELECT id, views, downloads FROM items ORDER BY id ASC LIMIT {} OFFSET {}'.format(limit, offset))
|
||||
results = cursor.fetchmany(limit)
|
||||
cursor.close()
|
||||
|
||||
# create a list to hold dicts of item stats
|
||||
statistics = list()
|
||||
|
||||
# iterate over results and build statistics object
|
||||
for item in results:
|
||||
statistics.append({ 'id': item['id'], 'views': item['views'], 'downloads': item['downloads'] })
|
||||
|
||||
message = {
|
||||
'currentPage': page,
|
||||
'totalPages': pages,
|
||||
'limit': limit,
|
||||
'statistics': statistics
|
||||
}
|
||||
|
||||
resp.media = message
|
||||
|
||||
class ItemResource:
|
||||
def on_get(self, req, resp, item_id):
|
||||
"""Handles GET requests"""
|
||||
|
||||
cursor = db.cursor()
|
||||
cursor.execute('SELECT views, downloads FROM items WHERE id={}'.format(item_id))
|
||||
if cursor.rowcount == 0:
|
||||
raise falcon.HTTPNotFound(
|
||||
title='Item not found',
|
||||
description='The item with id "{}" was not found.'.format(item_id)
|
||||
)
|
||||
else:
|
||||
results = cursor.fetchone()
|
||||
|
||||
statistics = {
|
||||
'id': item_id,
|
||||
'views': results['views'],
|
||||
'downloads': results['downloads']
|
||||
}
|
||||
|
||||
resp.media = statistics
|
||||
|
||||
cursor.close()
|
||||
|
||||
api = falcon.API()
|
||||
api.add_route('/items', AllItemsResource())
|
||||
api.add_route('/item/{item_id:int}', ItemResource())
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
11
config.py
11
config.py
@ -1,11 +0,0 @@
|
||||
import os
|
||||
|
||||
# Check if Solr connection information was provided in the environment
|
||||
SOLR_SERVER = os.environ.get('SOLR_SERVER', 'http://localhost:8080/solr')
|
||||
|
||||
DATABASE_NAME = os.environ.get('DATABASE_NAME', 'dspacestatistics')
|
||||
DATABASE_USER = os.environ.get('DATABASE_USER', 'dspacestatistics')
|
||||
DATABASE_PASS = os.environ.get('DATABASE_PASS', 'dspacestatistics')
|
||||
DATABASE_HOST = os.environ.get('DATABASE_HOST', 'localhost')
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
@ -9,10 +9,10 @@ Environment=DATABASE_PASS=dspacestatistics
|
||||
Environment=DATABASE_HOST=localhost
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
WorkingDirectory=/opt/ilri/dspace-statistics-api
|
||||
ExecStart=/opt/ilri/dspace-statistics-api/venv/bin/gunicorn \
|
||||
WorkingDirectory=/var/lib/dspace-statistics-api
|
||||
ExecStart=/var/lib/dspace-statistics-api/venv/bin/gunicorn \
|
||||
--bind 127.0.0.1:5000 \
|
||||
app:api
|
||||
dspace_statistics_api.app
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
ExecStop=/bin/kill -s TERM $MAINPID
|
||||
|
||||
|
@ -10,8 +10,8 @@ Environment=DATABASE_PASS=dspacestatistics
|
||||
Environment=DATABASE_HOST=localhost
|
||||
User=nobody
|
||||
Group=nogroup
|
||||
WorkingDirectory=/opt/ilri/dspace-statistics-api
|
||||
ExecStart=/opt/ilri/dspace-statistics-api/venv/bin/python indexer.py
|
||||
WorkingDirectory=/var/lib/dspace-statistics-api
|
||||
ExecStart=/var/lib/dspace-statistics-api/venv/bin/python -m dspace_statistics_api.indexer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
12
database.py
12
database.py
@ -1,12 +0,0 @@
|
||||
from config import DATABASE_NAME
|
||||
from config import DATABASE_USER
|
||||
from config import DATABASE_PASS
|
||||
from config import DATABASE_HOST
|
||||
import psycopg2, psycopg2.extras
|
||||
|
||||
def database_connection():
|
||||
connection = psycopg2.connect("dbname={} user={} password={} host='{}'".format(DATABASE_NAME, DATABASE_USER, DATABASE_PASS, DATABASE_HOST), cursor_factory=psycopg2.extras.DictCursor)
|
||||
|
||||
return connection
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
0
dspace_statistics_api/__init__.py
Normal file
0
dspace_statistics_api/__init__.py
Normal file
254
dspace_statistics_api/app.py
Normal file
254
dspace_statistics_api/app.py
Normal file
@ -0,0 +1,254 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import falcon
|
||||
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"
|
||||
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 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=1, max_value=100) or 100
|
||||
page = req.get_param_as_int("page", min_value=0) or 0
|
||||
offset = limit * page
|
||||
|
||||
with DatabaseManager() as db:
|
||||
db.set_read_only(True)
|
||||
|
||||
with db.cursor() as cursor:
|
||||
# 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()['count'] / limit)
|
||||
|
||||
# get statistics and use limit and offset to page through results
|
||||
cursor.execute(
|
||||
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 stats
|
||||
statistics = []
|
||||
|
||||
# iterate over results and build statistics object
|
||||
for result in cursor:
|
||||
statistics.append(
|
||||
{
|
||||
"id": str(result["id"]),
|
||||
"views": result["views"],
|
||||
"downloads": result["downloads"],
|
||||
}
|
||||
)
|
||||
|
||||
message = {
|
||||
"currentPage": page,
|
||||
"totalPages": pages,
|
||||
"limit": limit,
|
||||
"statistics": statistics,
|
||||
}
|
||||
|
||||
resp.media = message
|
||||
|
||||
@falcon.before(set_statistics_scope)
|
||||
@falcon.before(validate_post_parameters)
|
||||
def on_post(self, req, resp):
|
||||
"""Handles POST requests.
|
||||
|
||||
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"""
|
||||
|
||||
with DatabaseManager() as db:
|
||||
db.set_read_only(True)
|
||||
|
||||
with db.cursor() as cursor:
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
f"SELECT views, downloads FROM {req.context.database} WHERE id=%s",
|
||||
[str(id_)],
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise falcon.HTTPNotFound(
|
||||
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(id_),
|
||||
"views": results["views"],
|
||||
"downloads": results["downloads"],
|
||||
}
|
||||
|
||||
resp.media = statistics
|
||||
|
||||
|
||||
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:
|
23
dspace_statistics_api/config.py
Normal file
23
dspace_statistics_api/config.py
Normal file
@ -0,0 +1,23 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
import os
|
||||
|
||||
# Check if Solr connection information was provided in the environment
|
||||
SOLR_SERVER = os.environ.get("SOLR_SERVER", "http://localhost:8080/solr")
|
||||
|
||||
DATABASE_NAME = os.environ.get("DATABASE_NAME", "dspacestatistics")
|
||||
DATABASE_USER = os.environ.get("DATABASE_USER", "dspacestatistics")
|
||||
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.6-dev"
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
37
dspace_statistics_api/database.py
Normal file
37
dspace_statistics_api/database.py
Normal file
@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
import falcon
|
||||
import psycopg
|
||||
|
||||
from .config import (
|
||||
DATABASE_HOST,
|
||||
DATABASE_NAME,
|
||||
DATABASE_PASS,
|
||||
DATABASE_PORT,
|
||||
DATABASE_USER,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Manage database connection."""
|
||||
|
||||
def __init__(self):
|
||||
self._connection_uri = f"dbname={DATABASE_NAME} user={DATABASE_USER} password={DATABASE_PASS} host={DATABASE_HOST} port={DATABASE_PORT}"
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self._connection = psycopg.connect(
|
||||
self._connection_uri, row_factory=psycopg.rows.dict_row
|
||||
)
|
||||
except psycopg.OperationalError:
|
||||
title = "500 Internal Server Error"
|
||||
description = "Could not connect to database"
|
||||
raise falcon.HTTPInternalServerError(title, description)
|
||||
|
||||
return self._connection
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
self._connection.close()
|
||||
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
616
dspace_statistics_api/docs/openapi.json
Normal file
616
dspace_statistics_api/docs/openapi.json
Normal file
@ -0,0 +1,616 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"version": "1.4.6-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
231
dspace_statistics_api/indexer.py
Normal file
231
dspace_statistics_api/indexer.py
Normal file
@ -0,0 +1,231 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
#
|
||||
# indexer.py
|
||||
#
|
||||
# Connects to a DSpace Solr statistics core and ingests views and downloads for
|
||||
# communities, collections, and items into a PostgreSQL database.
|
||||
#
|
||||
# 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 psycopg
|
||||
#
|
||||
# See: https://wiki.duraspace.org/display/DSPACE/Solr
|
||||
|
||||
import math
|
||||
|
||||
import psycopg
|
||||
import requests
|
||||
|
||||
from .config import SOLR_SERVER
|
||||
from .database import DatabaseManager
|
||||
from .util import get_statistics_shards
|
||||
|
||||
|
||||
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 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": f"type:2 AND {facetField}:/.{{36}}/",
|
||||
"fq": "-isBot:true AND statistics_type:view",
|
||||
"fl": facetField,
|
||||
"facet": "true",
|
||||
"facet.field": facetField,
|
||||
"facet.mincount": 1,
|
||||
"facet.limit": 1,
|
||||
"facet.offset": 0,
|
||||
"stats": "true",
|
||||
"stats.field": facetField,
|
||||
"stats.calcdistinct": "true",
|
||||
"shards": shards,
|
||||
"rows": 0,
|
||||
"wt": "json",
|
||||
}
|
||||
|
||||
solr_url = SOLR_SERVER + "/statistics/select"
|
||||
|
||||
res = requests.get(solr_url, params=solr_query_params)
|
||||
|
||||
try:
|
||||
# get total number of distinct facets (countDistinct)
|
||||
results_totalNumFacets = res.json()["stats"]["stats_fields"][facetField][
|
||||
"countDistinct"
|
||||
]
|
||||
except TypeError:
|
||||
print(f"{indexType}: no views, exiting.")
|
||||
|
||||
exit(0)
|
||||
|
||||
# divide results into "pages" and round up to next integer
|
||||
results_per_page = 100
|
||||
results_num_pages = math.ceil(results_totalNumFacets / results_per_page)
|
||||
results_current_page = 0
|
||||
|
||||
with DatabaseManager() as db:
|
||||
with db.cursor() as cursor:
|
||||
# create an empty list to store values for batch insertion
|
||||
data = []
|
||||
|
||||
while results_current_page <= results_num_pages:
|
||||
# "pages" are zero based, but one based is more human readable
|
||||
print(
|
||||
f"{indexType}: indexing views (page {results_current_page + 1} of {results_num_pages + 1})"
|
||||
)
|
||||
|
||||
solr_query_params = {
|
||||
"q": f"type:2 AND {facetField}:/.{{36}}/",
|
||||
"fq": "-isBot:true AND statistics_type:view",
|
||||
"fl": facetField,
|
||||
"facet": "true",
|
||||
"facet.field": facetField,
|
||||
"facet.mincount": 1,
|
||||
"facet.limit": results_per_page,
|
||||
"facet.offset": results_current_page * results_per_page,
|
||||
"shards": shards,
|
||||
"rows": 0,
|
||||
"wt": "json",
|
||||
"json.nl": "map", # return facets as a dict instead of a flat list
|
||||
}
|
||||
|
||||
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 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 = f"INSERT INTO {indexType}(id, views) VALUES (%s, %s) ON CONFLICT(id) DO UPDATE SET views=excluded.views"
|
||||
cursor.executemany(sql, data)
|
||||
db.commit()
|
||||
|
||||
# clear all items from the list so we can populate it with the next batch
|
||||
data.clear()
|
||||
|
||||
results_current_page += 1
|
||||
|
||||
|
||||
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": f"type:0 AND {facetField}:/.{{36}}/",
|
||||
"fq": "-isBot:true AND statistics_type:view AND bundleName:ORIGINAL",
|
||||
"fl": facetField,
|
||||
"facet": "true",
|
||||
"facet.field": facetField,
|
||||
"facet.mincount": 1,
|
||||
"facet.limit": 1,
|
||||
"facet.offset": 0,
|
||||
"stats": "true",
|
||||
"stats.field": facetField,
|
||||
"stats.calcdistinct": "true",
|
||||
"shards": shards,
|
||||
"rows": 0,
|
||||
"wt": "json",
|
||||
}
|
||||
|
||||
solr_url = SOLR_SERVER + "/statistics/select"
|
||||
|
||||
res = requests.get(solr_url, params=solr_query_params)
|
||||
|
||||
try:
|
||||
# get total number of distinct facets (countDistinct)
|
||||
results_totalNumFacets = res.json()["stats"]["stats_fields"][facetField][
|
||||
"countDistinct"
|
||||
]
|
||||
except TypeError:
|
||||
print(f"{indexType}: no downloads, exiting.")
|
||||
|
||||
exit(0)
|
||||
|
||||
results_per_page = 100
|
||||
results_num_pages = math.ceil(results_totalNumFacets / results_per_page)
|
||||
results_current_page = 0
|
||||
|
||||
with DatabaseManager() as db:
|
||||
with db.cursor() as cursor:
|
||||
# create an empty list to store values for batch insertion
|
||||
data = []
|
||||
|
||||
while results_current_page <= results_num_pages:
|
||||
# "pages" are zero based, but one based is more human readable
|
||||
print(
|
||||
f"{indexType}: indexing downloads (page {results_current_page + 1} of {results_num_pages + 1})"
|
||||
)
|
||||
|
||||
solr_query_params = {
|
||||
"q": f"type:0 AND {facetField}:/.{{36}}/",
|
||||
"fq": "-isBot:true AND statistics_type:view AND bundleName:ORIGINAL",
|
||||
"fl": facetField,
|
||||
"facet": "true",
|
||||
"facet.field": facetField,
|
||||
"facet.mincount": 1,
|
||||
"facet.limit": results_per_page,
|
||||
"facet.offset": results_current_page * results_per_page,
|
||||
"shards": shards,
|
||||
"rows": 0,
|
||||
"wt": "json",
|
||||
"json.nl": "map", # return facets as a dict instead of a flat list
|
||||
}
|
||||
|
||||
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 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 = f"INSERT INTO {indexType}(id, downloads) VALUES (%s, %s) ON CONFLICT(id) DO UPDATE SET downloads=excluded.downloads"
|
||||
cursor.executemany(sql, data)
|
||||
db.commit()
|
||||
|
||||
# clear all items from the list so we can populate it with the next batch
|
||||
data.clear()
|
||||
|
||||
results_current_page += 1
|
||||
|
||||
|
||||
with DatabaseManager() as db:
|
||||
with db.cursor() as cursor:
|
||||
# create table to store item views and downloads
|
||||
cursor.execute(
|
||||
"""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 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:
|
126
dspace_statistics_api/stats.py
Normal file
126
dspace_statistics_api/stats.py
Normal 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:
|
193
dspace_statistics_api/util.py
Normal file
193
dspace_statistics_api/util.py
Normal 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:
|
173
indexer.py
173
indexer.py
@ -1,173 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# indexer.py
|
||||
#
|
||||
# Copyright 2018 Alan Orth.
|
||||
#
|
||||
# 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
|
||||
# can install with pip (I recommend using a Python virtual environment):
|
||||
#
|
||||
# $ pip install SolrClient psycopg2-binary
|
||||
#
|
||||
# See: https://solrclient.readthedocs.io/en/latest/SolrClient.html
|
||||
# See: https://wiki.duraspace.org/display/DSPACE/Solr
|
||||
|
||||
from database import database_connection
|
||||
import json
|
||||
import psycopg2.extras
|
||||
from solr import solr_connection
|
||||
|
||||
def index_views():
|
||||
# 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.
|
||||
#
|
||||
# see: https://lucene.apache.org/solr/guide/6_6/the-stats-component.html
|
||||
res = solr.query('statistics', {
|
||||
'q':'type:2',
|
||||
'fq':'isBot:false AND statistics_type:view',
|
||||
'facet':True,
|
||||
'facet.field':'id',
|
||||
'facet.mincount':1,
|
||||
'facet.limit':1,
|
||||
'facet.offset':0,
|
||||
'stats':True,
|
||||
'stats.field':'id',
|
||||
'stats.calcdistinct':True
|
||||
}, rows=0)
|
||||
|
||||
# get total number of distinct facets (countDistinct)
|
||||
results_totalNumFacets = json.loads(res.get_json())['stats']['stats_fields']['id']['countDistinct']
|
||||
|
||||
# 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_current_page = 0
|
||||
|
||||
cursor = db.cursor()
|
||||
|
||||
# create an empty list to store values for batch insertion
|
||||
data = []
|
||||
|
||||
while results_current_page <= results_num_pages:
|
||||
print('Indexing item views (page {} of {})'.format(results_current_page, results_num_pages))
|
||||
|
||||
res = solr.query('statistics', {
|
||||
'q':'type:2',
|
||||
'fq':'isBot:false AND statistics_type:view',
|
||||
'facet':True,
|
||||
'facet.field':'id',
|
||||
'facet.mincount':1,
|
||||
'facet.limit':results_per_page,
|
||||
'facet.offset':results_current_page * results_per_page
|
||||
}, rows=0)
|
||||
|
||||
# SolrClient's get_facets() returns a dict of dicts
|
||||
views = res.get_facets()
|
||||
# in this case 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))
|
||||
|
||||
# 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 downloads=excluded.views'
|
||||
psycopg2.extras.execute_values(cursor, sql, data, template='(%s, %s)')
|
||||
db.commit()
|
||||
|
||||
# clear all items from the list so we can populate it with the next batch
|
||||
data.clear()
|
||||
|
||||
results_current_page += 1
|
||||
|
||||
cursor.close()
|
||||
|
||||
def index_downloads():
|
||||
# get the total number of distinct facets for items with at least 1 download
|
||||
res = solr.query('statistics', {
|
||||
'q':'type:0',
|
||||
'fq':'isBot:false AND statistics_type:view AND bundleName:ORIGINAL',
|
||||
'facet':True,
|
||||
'facet.field':'owningItem',
|
||||
'facet.mincount':1,
|
||||
'facet.limit':1,
|
||||
'facet.offset':0,
|
||||
'stats':True,
|
||||
'stats.field':'owningItem',
|
||||
'stats.calcdistinct':True
|
||||
}, rows=0)
|
||||
|
||||
# get total number of distinct facets (countDistinct)
|
||||
results_totalNumFacets = json.loads(res.get_json())['stats']['stats_fields']['owningItem']['countDistinct']
|
||||
|
||||
# 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_current_page = 0
|
||||
|
||||
cursor = db.cursor()
|
||||
|
||||
# create an empty list to store values for batch insertion
|
||||
data = []
|
||||
|
||||
while results_current_page <= results_num_pages:
|
||||
print('Indexing item downloads (page {} of {})'.format(results_current_page, results_num_pages))
|
||||
|
||||
res = solr.query('statistics', {
|
||||
'q':'type:0',
|
||||
'fq':'isBot:false AND statistics_type:view AND bundleName:ORIGINAL',
|
||||
'facet':True,
|
||||
'facet.field':'owningItem',
|
||||
'facet.mincount':1,
|
||||
'facet.limit':results_per_page,
|
||||
'facet.offset':results_current_page * results_per_page
|
||||
}, rows=0)
|
||||
|
||||
# SolrClient's get_facets() returns a dict of dicts
|
||||
downloads = res.get_facets()
|
||||
# in this case 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))
|
||||
|
||||
# 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'
|
||||
psycopg2.extras.execute_values(cursor, sql, data, template='(%s, %s)')
|
||||
db.commit()
|
||||
|
||||
# clear all items from the list so we can populate it with the next batch
|
||||
data.clear()
|
||||
|
||||
results_current_page += 1
|
||||
|
||||
cursor.close()
|
||||
|
||||
db = database_connection()
|
||||
solr = solr_connection()
|
||||
|
||||
# create table to store item views and downloads
|
||||
cursor = db.cursor()
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS items
|
||||
(id INT PRIMARY KEY, views INT DEFAULT 0, downloads INT DEFAULT 0)''')
|
||||
index_views()
|
||||
index_downloads()
|
||||
|
||||
db.close()
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
43
pyproject.toml
Normal file
43
pyproject.toml
Normal file
@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "dspace-statistics-api"
|
||||
version = "1.4.6-dev"
|
||||
description = "A simple REST API to expose Solr view and download statistics for items, communities, and collections in a DSpace repository."
|
||||
authors = [
|
||||
{ name = "Alan Orth", email = "git@mjanja.mozmail.com" }
|
||||
]
|
||||
|
||||
requires-python = ">= 3.9"
|
||||
|
||||
license = "GPL-3.0-only"
|
||||
license-files = ["LICENSE.txt"]
|
||||
dependencies = [
|
||||
"gunicorn==23.0.*",
|
||||
"falcon==4.0.*",
|
||||
"psycopg==3.2.*",
|
||||
"requests==2.32.*",
|
||||
"falcon-swagger-ui",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
repository = "https://github.com/ilri/dspace-statistics-api"
|
||||
homepage = "https://github.com/ilri/dspace-statistics-api"
|
||||
|
||||
[tool.uv.sources]
|
||||
falcon-swagger-ui = { git = "https://github.com/alanorth/falcon-swagger-ui", rev = "falcon3-update-swagger-ui" }
|
||||
|
||||
# So uv doesn't fall back to setuptools
|
||||
# See: https://packaging.python.org/en/latest/tutorials/packaging-projects/#choosing-build-backend
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"flake8==7.1.*",
|
||||
"isort==5.13.*",
|
||||
"pytest==8.3.*",
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length=88
|
4
pytest.ini
Normal file
4
pytest.ini
Normal file
@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
addopts= -rsxX -s -v --strict-markers
|
||||
filterwarnings =
|
||||
error::UserWarning
|
9
renovate.json
Normal file
9
renovate.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:base"
|
||||
],
|
||||
"pip_requirements": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
54
requirements-dev.txt
Normal file
54
requirements-dev.txt
Normal file
@ -0,0 +1,54 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv export --no-hashes
|
||||
-e .
|
||||
certifi==2025.1.31
|
||||
# via requests
|
||||
charset-normalizer==3.4.1
|
||||
# via requests
|
||||
colorama==0.4.6 ; sys_platform == 'win32'
|
||||
# via pytest
|
||||
exceptiongroup==1.2.2 ; python_full_version < '3.11'
|
||||
# via pytest
|
||||
falcon==4.0.2
|
||||
# via
|
||||
# dspace-statistics-api
|
||||
# falcon-swagger-ui
|
||||
falcon-swagger-ui @ git+https://github.com/alanorth/falcon-swagger-ui@c019c270b479c03d9276e20fd95488495b0943f6
|
||||
# via dspace-statistics-api
|
||||
flake8==7.1.2
|
||||
gunicorn==23.0.0
|
||||
# via dspace-statistics-api
|
||||
idna==3.10
|
||||
# via requests
|
||||
iniconfig==2.1.0
|
||||
# via pytest
|
||||
isort==5.13.2
|
||||
jinja2==3.1.6
|
||||
# via falcon-swagger-ui
|
||||
markupsafe==3.0.2
|
||||
# via jinja2
|
||||
mccabe==0.7.0
|
||||
# via flake8
|
||||
packaging==24.2
|
||||
# via
|
||||
# gunicorn
|
||||
# pytest
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
psycopg==3.2.6
|
||||
# via dspace-statistics-api
|
||||
pycodestyle==2.12.1
|
||||
# via flake8
|
||||
pyflakes==3.2.0
|
||||
# via flake8
|
||||
pytest==8.3.5
|
||||
requests==2.32.3
|
||||
# via dspace-statistics-api
|
||||
tomli==2.2.1 ; python_full_version < '3.11'
|
||||
# via pytest
|
||||
typing-extensions==4.13.0 ; python_full_version < '3.13'
|
||||
# via psycopg
|
||||
tzdata==2025.2 ; sys_platform == 'win32'
|
||||
# via psycopg
|
||||
urllib3==2.3.0
|
||||
# via requests
|
@ -1,12 +1,33 @@
|
||||
certifi==2018.8.24
|
||||
chardet==3.0.4
|
||||
falcon==1.4.1
|
||||
gunicorn==19.9.0
|
||||
idna==2.7
|
||||
kazoo==2.5.0
|
||||
psycopg2-binary==2.7.5
|
||||
python-mimeparse==1.6.0
|
||||
requests==2.19.1
|
||||
six==1.11.0
|
||||
SolrClient==0.2.1
|
||||
urllib3==1.23
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv export --no-dev --no-hashes
|
||||
-e .
|
||||
certifi==2025.1.31
|
||||
# via requests
|
||||
charset-normalizer==3.4.1
|
||||
# via requests
|
||||
falcon==4.0.2
|
||||
# via
|
||||
# dspace-statistics-api
|
||||
# falcon-swagger-ui
|
||||
falcon-swagger-ui @ git+https://github.com/alanorth/falcon-swagger-ui@c019c270b479c03d9276e20fd95488495b0943f6
|
||||
# via dspace-statistics-api
|
||||
gunicorn==23.0.0
|
||||
# via dspace-statistics-api
|
||||
idna==3.10
|
||||
# via requests
|
||||
jinja2==3.1.6
|
||||
# via falcon-swagger-ui
|
||||
markupsafe==3.0.2
|
||||
# via jinja2
|
||||
packaging==24.2
|
||||
# via gunicorn
|
||||
psycopg==3.2.6
|
||||
# via dspace-statistics-api
|
||||
requests==2.32.3
|
||||
# via dspace-statistics-api
|
||||
typing-extensions==4.13.0 ; python_full_version < '3.13'
|
||||
# via psycopg
|
||||
tzdata==2025.2 ; sys_platform == 'win32'
|
||||
# via psycopg
|
||||
urllib3==2.3.0
|
||||
# via requests
|
||||
|
9
solr.py
9
solr.py
@ -1,9 +0,0 @@
|
||||
from config import SOLR_SERVER
|
||||
from SolrClient import SolrClient
|
||||
|
||||
def solr_connection():
|
||||
connection = SolrClient(SOLR_SERVER)
|
||||
|
||||
return connection
|
||||
|
||||
# vim: set sw=4 ts=4 expandtab:
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
1084
tests/dspacestatistics.sql
Normal file
1084
tests/dspacestatistics.sql
Normal file
File diff suppressed because it is too large
Load Diff
379
tests/test_api_collections.py
Normal file
379
tests/test_api_collections.py
Normal 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:
|
379
tests/test_api_communities.py
Normal file
379
tests/test_api_communities.py
Normal 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
50
tests/test_api_docs.py
Normal 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
379
tests/test_api_items.py
Normal 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:
|
486
uv.lock
generated
Normal file
486
uv.lock
generated
Normal file
@ -0,0 +1,486 @@
|
||||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.1.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dspace-statistics-api"
|
||||
version = "1.4.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "falcon" },
|
||||
{ name = "falcon-swagger-ui" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "psycopg" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "flake8" },
|
||||
{ name = "isort" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "falcon", specifier = "==4.0.*" },
|
||||
{ name = "falcon-swagger-ui", git = "https://github.com/alanorth/falcon-swagger-ui?rev=falcon3-update-swagger-ui" },
|
||||
{ name = "gunicorn", specifier = "==23.0.*" },
|
||||
{ name = "psycopg", specifier = "==3.2.*" },
|
||||
{ name = "requests", specifier = "==2.32.*" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "flake8", specifier = "==7.1.*" },
|
||||
{ name = "isort", specifier = "==5.13.*" },
|
||||
{ name = "pytest", specifier = "==8.3.*" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "falcon"
|
||||
version = "4.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/4f/d317952294dee1982cd930c8ee2b8b7fbf04140473882801061b3346c713/falcon-4.0.2.tar.gz", hash = "sha256:58f4b9c9da4c9b1e2c9f396ad7ef897701b3c7c7c87227f0bd1aee40c7fbc525", size = 630121 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/75/e33013aedec976d13f2104ab2e054b5e3863b518c9b28239d2837b521d7f/falcon-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8eab0212e77017385d48be2dfe9f5b32305fc9e4066cd298e4bb39e666e114c8", size = 2315204 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5f/4a3ccb6d8bdb4cfcc38aea9cd5e5c49aea400305f581c839be206c3a93e2/falcon-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:942129dd3bfb56342ac368f05ff4f9be53e98883b4227089fce2fd616ebc6ef3", size = 2194755 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/99/447d6f8618b3f8b882c7e74eafabb59f6e9112acbc3255dddd353fc75505/falcon-4.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60e7b6e5ee44bb2411a7f47bb64e0b225f11cca6ddf91e5130d456242095f0d7", size = 10390701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/27/a93dc68be1e70809cfa6d227424790ff502cc1f4272200bb91ebe92fafb1/falcon-4.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:330f1623e579575a9e3d90c2a15aebe100b2afa1e18a4bee2ddaa9a570e97902", size = 10986193 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/22/aae29170b0947a5170844bf74b671b5e0e6dc218e2cc6262d2378ec44d15/falcon-4.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d7cfac5cfca69373d1f65211d75767ed4f2d53b46554307427ec00a6f7f87c1", size = 10511133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/73/0862e66b5b9c5295f065ee7c83571fd106dd84d63cf0479038986ddf2881/falcon-4.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:da3d942dd34f7a5213987bd053c3b52b6eb75fcfd342dc4fea9241f79a6529b3", size = 10201951 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/4f/044454ad96a542f2c446a07b6ebc8da0bef8e7e689e32aeb2bf40594a712/falcon-4.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5169e064bbe5dece52e088e3e8b17cae429f1e04c7aef8c31ae350303b19c620", size = 10633849 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/73/bd689c2790c42b6287df1a43928ae3af6cd2541ac1d64d6fa2fc960dad5b/falcon-4.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:0d62e565b9e71b52b59e03130b2b71345a6873f5299aad6a141caf4a58661b41", size = 2115914 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/e3/dfeff966d60f2308f765736044e0a62f046d2420baf50fa4872b06338fd5/falcon-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb6ee1aee9ff6a656762cf5fcd2e6c5dced410ca990016be2bc193e6b74ae9da", size = 2321291 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/02/8a1a68ddf9b6f9d4a7d0d63f0a485109318f08c0181a3d9f4b05dceab355/falcon-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f1a16d8bdc8ef9cf2832a6ca6d43b156b613fb1587cd08cc928c7b8a118ea0a", size = 2196225 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/64/6fa45987bd0fc78d991be5ea0e30f0812eb26713c2e99ce07e35c959346b/falcon-4.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aee81fc4702eef5bccb640b93187fdf36ca2606fca511982069dbc60be2d1c93", size = 11603829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/bc1d47ee32e2a211cffca346bd935009d2b37189c2119df95c31a9af6231/falcon-4.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c1dbcec63d9118c3dfac1f810305128c4fffe26f4f99a7b4e379dec95fc3bfc", size = 12146705 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/cd/ef07fd256c2a29d3c3f1cc22e0ce59724450eb13ef5ca553e63b6abd19f7/falcon-4.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2892ab1232d3a7cc9890b1b539c471fe04c54f826704f9d05efe5632f18efa1", size = 11693278 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/2b/e03066e7be01f1b09c87ecd9c48e14a0ddafd9fc8fddd05db27b5ad4e3d9/falcon-4.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:af68482b408bde53a77b36e45317767dfc5b6fce1525f5b25d65f57f35d33fca", size = 11379786 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/15/f218b581df1447f743b16812c84ab8f6f7d51fb3c1950129744f6fd653bc/falcon-4.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:53d84de83abe1a2094b319a4f018ab6c5773d9c2c841b528662aa151ab9df35c", size = 11770361 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3e/855c3051cb8aad61a921959e5e62416d759761241c1da8394103c3d1d6af/falcon-4.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:1d06bbbccdb58522b2a6bb2e79074844b0db0da1fff407725858a02515e15bbd", size = 2124712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/db/0b78b7ee3fe7e370ed430b7deabfa524b57a5b9eb32622ce1f1bb7aacf0d/falcon-4.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:23b0419a9a025745734022aaa2e65447595e539ba27352b3f59d86b288f614db", size = 2294078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/01/77b5b0214bc4ca717b6c6cbe8c3adaba653a7312c9c51a9b390f66efbce0/falcon-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:524d7b75f7368fe82e94ed16370db5a27bb4b2d066470cba53f02304264447e8", size = 2186007 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/0c/44abd34e38b88f15c5a7030f48ec079669218af3162de2bd1925e13a46a5/falcon-4.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c6b1d7451d5dee4be9b67a75e2a4a0b024dccffedd4e7c7a09513733b5a11db", size = 11644341 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f3/0260f70dd080d23372e2ff0e330ca37897ab5e1b4890df281558bda8e34a/falcon-4.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59bb4a29626c5e610c62620a1395755e8c7b5509385b80d3637fbc8a604d29a3", size = 12218712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/704b73fc76cf283504aaacc2f466a08fd5d440cddd8d50b6d5c288f0293b/falcon-4.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26c9ed2912ee48e2e1e7eca3e7e85ab664ff07bd321097a26e4ad6168059424", size = 11842131 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9a/53f9ee7ee8758972d92bb3dfb2225a9c382fbd12f684616cab9126420602/falcon-4.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a12bbf3482b7ef1db0c6727c2ad8be5c3ac777d892e56a170e0b4b93651c915", size = 11346087 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/ac/8098957dd5b97ed16788104b7acb33c64689f7ab04e0c6b07d6561182950/falcon-4.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a601de7816138f17bf168262e0bceb128fdd1ea2f29ddae035585b5da9223a21", size = 11992383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/23/f8a74294b5b0cb5b9e3eb44ccea310a5d480ef95e938704827db0dd97f99/falcon-4.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:eec3feda4a9cd773203401e3cf425728a13bf5055b22243b1452e9ad963634f5", size = 2077409 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/34/71ef64406ac7f83c5726a37c9fcae0578bc9d650de09c32148aa6c58502f/falcon-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:110b172afe337fbae802f1402c89a5dfe6392f3b8ce4f2ecdfd5cee48f68b805", size = 2257265 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/528d074e8a75a9236c9f060685e4cb813fbca774269afc89d31e821d8560/falcon-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b3a5db14cf2ef05f8f9630468c03939b86dc16115a5250a1870dac3dca1e04ba", size = 2151133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/8c7bf8bf941238a87debce72fcdc7b2301d6599271a392c8216ea2f5d91e/falcon-4.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b4d41ce29c2b5c5b18021320e9e0977ba47ade46b67face52ee1325e2ea4", size = 11438997 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/43/71e358d36ec4559737d63312d746fb5f8b0e64f1fe273cd6991e567a9225/falcon-4.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56af3b8838da2e19ae56b4e1bac168669ba257d6941f94933dc4f814fe721c08", size = 12015197 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/70/c10acaa3486748f77d9b0e79aaa19d3023b760bb9b93389ac1883a52e366/falcon-4.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec939d26dd77f57f08f3e13fb14b4e609c0baf073dc3f0c368f0e4cc10439528", size = 11653687 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/3b/dfdd9bd9f6114a49a55298b12048f1b65d0813b82c28676b956c4444f707/falcon-4.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bfd751dd898505e17152d7ecfcdc457c9d85bceed7e651a9915183bd4afc86b", size = 11165291 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/61/eb3d1d2076df85d5a7c2cd823ba5dbe0a928053a3102effb9006b2851377/falcon-4.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b85f9c6f50a7465303290cb305404ea5c1ddeff6702179c1a8879c4693b0e5e", size = 11831049 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c7/268cddb1f84ebe5b402acdf116083658f3fb0dd38a75571e0ee703cef212/falcon-4.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:a410e4023999a74ccf615fafa646b112044b987ef5901c8e5c5b79b163f2b3ba", size = 2052994 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/7be6347247812e6553be50d83b0951e569d597b9c3a71e4c0de5b00789b7/falcon-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9709fd9181f58d492463b951cc42fb33b230e8f261128bc8252a37a4553f318", size = 2323679 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/71/65266fc8433e396f42a2b045e7b7069390c4314e8b66e66c4e092166226f/falcon-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:427c20ceb367039b856506d7baeef17c7f0c40b8fcbf1147c0e76f33a574a7cf", size = 2203699 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/30/82ff9d61d6baa5f908ccbbb775585cd1ac24dd47fcc2d2e6c5b9b0f44ce4/falcon-4.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fb50cebc3cae6720ccf4a05fccb233ea6a88e803828a07c063d6dce10a74e0e", size = 10418688 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e5/660262ee87a90aab812d2d10ab42e6e0bffe853890b2e14268863c4dd659/falcon-4.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:628c450e14af811f13db6334265d7ff8a7b8a25ece1bde35d09a367a72046533", size = 11033845 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b2/cf4c9567ad571e3304743e6f65c42576929cc16d494cb234f972fa70a150/falcon-4.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e04b30a7f89e5413e00c5cd1ea62bf7948323eb0220f8a5bbf705abae266a384", size = 10547880 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b8/503d3574be76a09b64dd48214e012fd1b911a158400bc04b1ee0d4caec0f/falcon-4.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9095a36b8eeb80207322393b3bc88edaacd0426c2907e8427617618421bde9cc", size = 10218888 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/88/d96a3e9d93aee74280a82be844c2eaa603283c5548b3293165deb2d55b4e/falcon-4.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0adc6c2887f9d7ed55fe38edef055cc85c26762e392d80dca8765184c180b921", size = 10663138 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/97/4021fce87e3feb67839405ca8d2560d989da141692214c6f1b297af23443/falcon-4.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:7bffb4cadcbf7c5994695d421ef5305ad8315cfbefe971713046967614f0ffa4", size = 2121203 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e2/ef821224a9ca9d4bb81d6e7ba60c6fbf3eae2e0dc10d806e6ff21b6dfdc5/falcon-4.0.2-py3-none-any.whl", hash = "sha256:077b2abf001940c6128c9b5872ae8147fe13f6ca333f928d8045d7601a5e847e", size = 318356 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "falcon-swagger-ui"
|
||||
version = "1.2.1"
|
||||
source = { git = "https://github.com/alanorth/falcon-swagger-ui?rev=falcon3-update-swagger-ui#c019c270b479c03d9276e20fd95488495b0943f6" }
|
||||
dependencies = [
|
||||
{ name = "falcon" },
|
||||
{ name = "jinja2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mccabe" },
|
||||
{ name = "pycodestyle" },
|
||||
{ name = "pyflakes" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "23.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/97/eea08f74f1c6dd2a02ee81b4ebfe5b558beb468ebbd11031adbf58d31be0/psycopg-3.2.6.tar.gz", hash = "sha256:16fa094efa2698f260f2af74f3710f781e4a6f226efe9d1fd0c37f384639ed8a", size = 156322 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7d/0ba52deff71f65df8ec8038adad86ba09368c945424a9bd8145d679a2c6a/psycopg-3.2.6-py3-none-any.whl", hash = "sha256:f3ff5488525890abb0566c429146add66b329e20d6d4835662b920cbbf90ac58", size = 199077 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycodestyle"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
||||
]
|
Reference in New Issue
Block a user