diff options
author | Jordan Cook <JWCook@users.noreply.github.com> | 2021-09-18 18:29:51 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-09-18 18:29:51 -0500 |
commit | d1462b16eb479db766cf179829506c5238c9dde8 (patch) | |
tree | e228c64bc3a32d06a903e8c33ddd662f34a3e9d2 | |
parent | 1fa0fbb715e5964eb8aee467721f6b5a0f4cfdb6 (diff) | |
parent | e32306baeed781cd93f0cc92220b1915f00af793 (diff) | |
download | requests-cache-d1462b16eb479db766cf179829506c5238c9dde8.tar.gz |
Merge pull request #406 from JWCook/thread-safe-request-expire-after
Make per-request expiration thread-safe
-rw-r--r-- | .all-contributorsrc | 18 | ||||
-rw-r--r-- | CONTRIBUTORS.md | 18 | ||||
-rw-r--r-- | HISTORY.md | 5 | ||||
-rw-r--r-- | docs/user_guide/expiration.md | 4 | ||||
-rw-r--r-- | docs/user_guide/headers.md | 7 | ||||
-rw-r--r-- | poetry.lock | 13 | ||||
-rw-r--r-- | pyproject.toml | 6 | ||||
-rw-r--r-- | requests_cache/cache_control.py | 72 | ||||
-rw-r--r-- | requests_cache/session.py | 34 | ||||
-rw-r--r-- | tests/integration/base_cache_test.py | 26 | ||||
-rw-r--r-- | tests/unit/test_cache_control.py | 49 | ||||
-rw-r--r-- | tests/unit/test_session.py | 38 |
12 files changed, 157 insertions, 133 deletions
diff --git a/.all-contributorsrc b/.all-contributorsrc index 239d83f..29dabcb 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -659,6 +659,24 @@ "contributions": [ "bug" ] + }, + { + "login": "RodericDay", + "name": "Roderic Day", + "avatar_url": "https://avatars.githubusercontent.com/u/6867226?v=4", + "profile": "https://roderic.ca/", + "contributions": [ + "bug" + ] + }, + { + "login": "jonasjancarik", + "name": "Jonáš Jančařík", + "avatar_url": "https://avatars.githubusercontent.com/u/2459191?v=4", + "profile": "https://github.com/jonasjancarik", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7be950c..22de311 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -46,50 +46,52 @@ contributions that have helped to improve requests-cache: </tr> <tr> <td align="center"><a href="https://www.openhub.net/accounts/jayvdb"><img src="https://avatars.githubusercontent.com/u/15092?v=4?s=100" width="100px;" alt=""/><br /><sub><b>John Vandenberg</b></sub></a><br /><a href="#infra-jayvdb" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#platform-jayvdb" title="Packaging/porting to new platform">📦</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=jayvdb" title="Tests">⚠️</a></td> + <td align="center"><a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonáš Jančařík</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Ajonasjancarik" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/JWCook"><img src="https://avatars.githubusercontent.com/u/419936?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jordan Cook</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=JWCook" title="Code">💻</a> <a href="#maintenance-JWCook" title="Maintenance">🚧</a> <a href="#feature-JWCook" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AJWCook" title="Bug reports">🐛</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=JWCook" title="Tests">⚠️</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=JWCook" title="Documentation">📖</a> <a href="#infra-JWCook" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> <td align="center"><a href="http://jhermann.github.io/"><img src="https://avatars.githubusercontent.com/u/1068245?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jürgen Hermann</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Ajhermann" title="Bug reports">🐛</a> <a href="#ideas-jhermann" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://github.com/FredHappyface"><img src="https://avatars.githubusercontent.com/u/41634689?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kieran W</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=FredHappyface" title="Documentation">📖</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AFredHappyface" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/MHellmund"><img src="https://avatars.githubusercontent.com/u/1593619?v=4?s=100" width="100px;" alt=""/><br /><sub><b>MHellmund</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AMHellmund" title="Bug reports">🐛</a></td> <td align="center"><a href="http://marc-abramowitz.com/"><img src="https://avatars.githubusercontent.com/u/305268?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marc Abramowitz</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=msabramo" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=msabramo" title="Documentation">📖</a></td> - <td align="center"><a href="https://gedmin.as/"><img src="https://avatars.githubusercontent.com/u/159967?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marius Gedminas</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=mgedmin" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Amgedmin" title="Bug reports">🐛</a></td> </tr> <tr> + <td align="center"><a href="https://gedmin.as/"><img src="https://avatars.githubusercontent.com/u/159967?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marius Gedminas</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=mgedmin" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Amgedmin" title="Bug reports">🐛</a></td> <td align="center"><a href="https://lab.ar90n.net/"><img src="https://avatars.githubusercontent.com/u/2285892?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Masahiro Wada</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=ar90n" title="Code">💻</a> <a href="#feature-ar90n" title="New features">✨</a></td> <td align="center"><a href="https://santini.di.unimi.it/"><img src="https://avatars.githubusercontent.com/u/612826?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Massimo Santini</b></sub></a><br /><a href="#ideas-mapio" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="http://www.mherman.org/"><img src="https://avatars.githubusercontent.com/u/2018167?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Herman</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=mjhea0" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=mjhea0" title="Documentation">📖</a></td> <td align="center"><a href="https://mgorny.pl/"><img src="https://avatars.githubusercontent.com/u/110765?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michał Górny</b></sub></a><br /><a href="#infra-mgorny" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> <td align="center"><a href="https://github.com/mnowotka"><img src="https://avatars.githubusercontent.com/u/837119?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michał Nowotka</b></sub></a><br /><a href="#ideas-mnowotka" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://beaumont.dev/"><img src="https://avatars.githubusercontent.com/u/2266568?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mike</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=michaelbeaumont" title="Code">💻</a> <a href="#feature-michaelbeaumont" title="New features">✨</a></td> - <td align="center"><a href="https://nathancahill.com/"><img src="https://avatars.githubusercontent.com/u/1383872?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nathan Cahill</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Anathancahill" title="Bug reports">🐛</a></td> </tr> <tr> + <td align="center"><a href="https://nathancahill.com/"><img src="https://avatars.githubusercontent.com/u/1383872?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nathan Cahill</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Anathancahill" title="Bug reports">🐛</a></td> <td align="center"><a href="https://gitlab.com/kousu"><img src="https://avatars.githubusercontent.com/u/987487?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nick</b></sub></a><br /><a href="#ideas-kousu" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://github.com/olivierdalang"><img src="https://avatars.githubusercontent.com/u/1894106?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Olivier Dalang</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=olivierdalang" title="Code">💻</a></td> <td align="center"><a href="https://github.com/parkerhancock"><img src="https://avatars.githubusercontent.com/u/633163?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Parker Hancock</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=parkerhancock" title="Code">💻</a> <a href="#feature-parkerhancock" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Aparkerhancock" title="Bug reports">🐛</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=parkerhancock" title="Tests">⚠️</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=parkerhancock" title="Documentation">📖</a> <a href="#security-parkerhancock" title="Security">🛡️</a> <a href="#ideas-parkerhancock" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://phil.red/"><img src="https://avatars.githubusercontent.com/u/291575?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Philipp A.</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Aflying-sheep" title="Bug reports">🐛</a></td> + <td align="center"><a href="https://roderic.ca/"><img src="https://avatars.githubusercontent.com/u/6867226?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roderic Day</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3ARodericDay" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/reclosedev"><img src="https://avatars.githubusercontent.com/u/660112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roman Haritonov</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=reclosedev" title="Code">💻</a> <a href="#maintenance-reclosedev" title="Maintenance">🚧</a> <a href="#feature-reclosedev" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Areclosedev" title="Bug reports">🐛</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=reclosedev" title="Tests">⚠️</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=reclosedev" title="Documentation">📖</a> <a href="#infra-reclosedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> - <td align="center"><a href="https://www.facebook.com/avasamdev"><img src="https://avatars.githubusercontent.com/u/1350584?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Samuel T.</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AAvasam" title="Bug reports">🐛</a> <a href="#ideas-Avasam" title="Ideas, Planning, & Feedback">🤔</a></td> - <td align="center"><a href="https://sebastian-hoeffner.de/"><img src="https://avatars.githubusercontent.com/u/1836815?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sebastian Höffner</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=shoeffner" title="Code">💻</a> <a href="#feature-shoeffner" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=shoeffner" title="Tests">⚠️</a> <a href="#ideas-shoeffner" title="Ideas, Planning, & Feedback">🤔</a></td> </tr> <tr> + <td align="center"><a href="https://www.facebook.com/avasamdev"><img src="https://avatars.githubusercontent.com/u/1350584?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Samuel T.</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AAvasam" title="Bug reports">🐛</a> <a href="#ideas-Avasam" title="Ideas, Planning, & Feedback">🤔</a></td> + <td align="center"><a href="https://sebastian-hoeffner.de/"><img src="https://avatars.githubusercontent.com/u/1836815?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sebastian Höffner</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=shoeffner" title="Code">💻</a> <a href="#feature-shoeffner" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=shoeffner" title="Tests">⚠️</a> <a href="#ideas-shoeffner" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://sbiewald.de/"><img src="https://avatars.githubusercontent.com/u/5983372?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon Biewald</b></sub></a><br /><a href="#security-Varbin" title="Security">🛡️</a> <a href="#ideas-Varbin" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="http://pathmind.com/"><img src="https://avatars.githubusercontent.com/u/1197406?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Slin Lee</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=slinlee" title="Documentation">📖</a></td> <td align="center"><a href="https://www.stavros.io/"><img src="https://avatars.githubusercontent.com/u/23648?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Stavros Korokithakis</b></sub></a><br /><a href="#infra-skorokithakis" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#tool-skorokithakis" title="Tools">🔧</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=skorokithakis" title="Documentation">📖</a></td> <td align="center"><a href="https://vladimir.panteleev.md/"><img src="https://avatars.githubusercontent.com/u/160894?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vladimir Panteleev</b></sub></a><br /><a href="#ideas-CyberShadow" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://sansec.io/"><img src="https://avatars.githubusercontent.com/u/1145479?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Willem de Groot</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=gwillem" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Agwillem" title="Bug reports">🐛</a></td> - <td align="center"><a href="https://github.com/WouterVH"><img src="https://avatars.githubusercontent.com/u/469509?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Wouter Vanden Hove</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AWouterVH" title="Bug reports">🐛</a></td> - <td align="center"><a href="https://github.com/YetAnotherNerd"><img src="https://avatars.githubusercontent.com/u/320738?v=4?s=100" width="100px;" alt=""/><br /><sub><b>YetAnotherNerd</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=YetAnotherNerd" title="Code">💻</a> <a href="#feature-YetAnotherNerd" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AYetAnotherNerd" title="Bug reports">🐛</a></td> </tr> <tr> + <td align="center"><a href="https://github.com/WouterVH"><img src="https://avatars.githubusercontent.com/u/469509?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Wouter Vanden Hove</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AWouterVH" title="Bug reports">🐛</a></td> + <td align="center"><a href="https://github.com/YetAnotherNerd"><img src="https://avatars.githubusercontent.com/u/320738?v=4?s=100" width="100px;" alt=""/><br /><sub><b>YetAnotherNerd</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=YetAnotherNerd" title="Code">💻</a> <a href="#feature-YetAnotherNerd" title="New features">✨</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3AYetAnotherNerd" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/aaron-mf1"><img src="https://avatars.githubusercontent.com/u/65560918?v=4?s=100" width="100px;" alt=""/><br /><sub><b>aaron-mf1</b></sub></a><br /><a href="#ideas-aaron-mf1" title="Ideas, Planning, & Feedback">🤔</a></td> <td align="center"><a href="https://github.com/coryairbhb"><img src="https://avatars.githubusercontent.com/u/50755629?v=4?s=100" width="100px;" alt=""/><br /><sub><b>coryairbhb</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Acoryairbhb" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/craigls"><img src="https://avatars.githubusercontent.com/u/972350?v=4?s=100" width="100px;" alt=""/><br /><sub><b>craig</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=craigls" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Acraigls" title="Bug reports">🐛</a></td> <td align="center"><a href="https://stackoverflow.com/users/86643/denis"><img src="https://avatars.githubusercontent.com/u/1280390?v=4?s=100" width="100px;" alt=""/><br /><sub><b>denis-bz</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Adenis-bz" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/gorogoroumaru"><img src="https://avatars.githubusercontent.com/u/30716350?v=4?s=100" width="100px;" alt=""/><br /><sub><b>gorogoroumaru</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=gorogoroumaru" title="Code">💻</a></td> - <td align="center"><a href="https://github.com/harvey251"><img src="https://avatars.githubusercontent.com/u/33844174?v=4?s=100" width="100px;" alt=""/><br /><sub><b>harvey251</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Aharvey251" title="Bug reports">🐛</a></td> - <td align="center"><a href="https://github.com/mbarkhau"><img src="https://avatars.githubusercontent.com/u/446561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mbarkhau</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=mbarkhau" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=mbarkhau" title="Tests">⚠️</a> <a href="#infra-mbarkhau" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Ambarkhau" title="Bug reports">🐛</a></td> </tr> <tr> + <td align="center"><a href="https://github.com/harvey251"><img src="https://avatars.githubusercontent.com/u/33844174?v=4?s=100" width="100px;" alt=""/><br /><sub><b>harvey251</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Aharvey251" title="Bug reports">🐛</a></td> + <td align="center"><a href="https://github.com/mbarkhau"><img src="https://avatars.githubusercontent.com/u/446561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mbarkhau</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=mbarkhau" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/commits?author=mbarkhau" title="Tests">⚠️</a> <a href="#infra-mbarkhau" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Ambarkhau" title="Bug reports">🐛</a></td> <td align="center"><a href="https://github.com/shiftinv"><img src="https://avatars.githubusercontent.com/u/8530778?v=4?s=100" width="100px;" alt=""/><br /><sub><b>shiftinv</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/commits?author=shiftinv" title="Code">💻</a> <a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Ashiftinv" title="Bug reports">🐛</a></td> <td align="center"><a href="https://www.witionstheme.com/"><img src="https://avatars.githubusercontent.com/u/55755139?v=4?s=100" width="100px;" alt=""/><br /><sub><b>witionstheme</b></sub></a><br /><a href="https://github.com/reclosedev/requests-cache/issues?q=author%3Awitionstheme" title="Bug reports">🐛</a></td> </tr> @@ -1,5 +1,10 @@ # History +## 0.8.2 (Unreleased) +* Use `Cache-Control` **request** headers by default +* Support `expire_after` param for `CachedSession.send()` +* Make per-request expiration thread-safe for both `CachedSession.request()` and `CachedSession.send()` + ## 0.8.1 (2021-09-15) * Redact `ingored_parameters` from `CachedResponse.url` (if used for credentials or other sensitive info) * Fix an incorrect debug log message about skipping cache write diff --git a/docs/user_guide/expiration.md b/docs/user_guide/expiration.md index 6ef3dbf..37bc010 100644 --- a/docs/user_guide/expiration.md +++ b/docs/user_guide/expiration.md @@ -16,8 +16,8 @@ reponses: Expiration can be set on a per-session, per-URL, or per-request basis, in addition to cache headers (see sections below for usage details). When there are multiple values provided for a given request, the following order of precedence is used: -1. Cache-Control request headers (if enabled) -2. Cache-Control response headers (if enabled) +1. Cache-Control response headers (if enabled) +2. Cache-Control request headers 3. Per-request expiration (`expire_after` argument for {py:meth}`.CachedSession.request`) 4. Per-URL expiration (`urls_expire_after` argument for {py:class}`.CachedSession`) 5. Per-session expiration (`expire_after` argument for {py:class}`.CacheBackend`) diff --git a/docs/user_guide/headers.md b/docs/user_guide/headers.md index 7aade73..a1c7e2f 100644 --- a/docs/user_guide/headers.md +++ b/docs/user_guide/headers.md @@ -32,8 +32,11 @@ True, True ``` ## Cache-Control -If enabled, `Cache-Control` directives will take priority over any other `expire_after` value. -See {ref}`precedence` for the full order of precedence. +`Cache-Control` request headers will be used if present. This is mainly useful for patching an +existing library that sets request headers. + +`Cache-Control` response headers are an opt-in feature. If enabled, these will take priority over +any other `expire_after` values. See {ref}`precedence` for the full order of precedence. To enable this behavior, use the `cache_control` option: ```python diff --git a/poetry.lock b/poetry.lock index aa78aed..87c7cd9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -774,18 +774,19 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 [[package]] name = "responses" -version = "0.10.15" +version = "0.14.0" description = "A utility library for mocking out the `requests` Python library." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] requests = ">=2.0" six = "*" +urllib3 = ">=1.25.10" [package.extras] -tests = ["coverage (>=3.7.1,<5.0.0)", "pytest-cov", "pytest-localserver", "flake8", "pytest (>=4.6,<5.0)", "pytest"] +tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "types-mock", "types-requests", "types-six", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"] [[package]] name = "rich" @@ -1186,7 +1187,7 @@ yaml = ["pyyaml"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "b52b14c89f2e356007f8bb10a48f7b3a1f069545188d5c6994f8809d33b11d3f" +content-hash = "6ffb70cf976fb4572ebbc0f4361a7598e16c1a053bc222626636769b44537575" [metadata.files] alabaster = [ @@ -1694,8 +1695,8 @@ requests-mock = [ {file = "requests_mock-1.9.3-py2.py3-none-any.whl", hash = "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970"}, ] responses = [ - {file = "responses-0.10.15-py2.py3-none-any.whl", hash = "sha256:af94d28cdfb48ded0ad82a5216616631543650f440334a693479b8991a6594a2"}, - {file = "responses-0.10.15.tar.gz", hash = "sha256:7bb697a5fedeb41d81e8b87f152d453d5cab42dcd1691b6a7d6097e94d33f373"}, + {file = "responses-0.14.0-py2.py3-none-any.whl", hash = "sha256:57bab4e9d4d65f31ea5caf9de62095032c4d81f591a8fac2f5858f7777b8567b"}, + {file = "responses-0.14.0.tar.gz", hash = "sha256:93f774a762ee0e27c0d9d7e06227aeda9ff9f5f69392f72bb6c6b73f8763563e"}, ] rich = [ {file = "rich-10.10.0-py3-none-any.whl", hash = "sha256:0b8cbcb0b8d476a7f002feaed9f35e51615f673c6c291d76ddf0c555574fd3c7"}, diff --git a/pyproject.toml b/pyproject.toml index 8831a33..b0ed0a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,14 +50,14 @@ ujson = {optional=true, version=">=4.0"} # All the bells and whistles for building documentation; # defined here because readthedocs doesn't (yet?) support poetry.dev-dependencies -furo = {optional=true, version=">=2021.8.11-beta.42"} +furo = {optional=true, version=">=2021.9.8"} linkify-it-py = {optional=true, version="^1.0.1"} myst-parser = {optional=true, version="^0.15.1"} sphinx = {optional=true, version="4.2.0"} sphinx-autodoc-typehints = {optional=true, version="^1.11"} sphinx-automodapi = {optional=true, version="^0.13"} sphinx-copybutton = {optional=true, version=">=0.3,<0.5"} -sphinx-inline-tabs = {optional=true, version="^2021.4.11-beta.9", python=">=3.8"} +sphinx-inline-tabs = {optional=true, version="^2021.8.17b10", python=">=3.8"} sphinx-notfound-page = {optional=true, version="*"} sphinx-panels = {optional=true, version="^0.6"} sphinxcontrib-apidoc = {optional=true, version="^0.3"} @@ -91,7 +91,7 @@ pytest-cov = ">=2.11" pytest-rerunfailures = "^10.1" pytest-xdist = ">=2.2" requests-mock = "^1.8" -responses = "0.10.15" +responses = "0.14" timeout-decorator = "^0.5" # Tools for linting, type checking, etc. are managed with pre-commit diff --git a/requests_cache/cache_control.py b/requests_cache/cache_control.py index b3d1703..418ac29 100644 --- a/requests_cache/cache_control.py +++ b/requests_cache/cache_control.py @@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone from email.utils import parsedate_to_datetime from fnmatch import fnmatch from logging import getLogger +from math import ceil from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple, Union from attr import define, field @@ -47,7 +48,7 @@ class CacheActions: If multiple sources provide an expiration time, they will be used in the following order of precedence: - 1. Cache-Control request headers (if enabled) + 1. Cache-Control request headers 2. Cache-Control response headers (if enabled) 3. Per-request expiration 4. Per-URL expiration @@ -67,53 +68,28 @@ class CacheActions: cache_key: str, request: PreparedRequest, cache_control: bool = False, - **kwargs, - ): - """Initialize from request info and cache settings""" - if cache_control and has_cache_headers(request.headers): - return cls.from_headers(cache_key, request.headers) - else: - return cls.from_settings(cache_key, request.url, cache_control=cache_control, **kwargs) - - @classmethod - def from_headers(cls, cache_key: str, headers: Mapping): - """Initialize from request headers""" - directives = get_cache_directives(headers) - do_not_cache = directives.get('max-age') == DO_NOT_CACHE - return cls( - cache_control=True, - cache_key=cache_key, - expire_after=directives.get('max-age'), - skip_read=do_not_cache or 'no-store' in directives or 'no-cache' in directives, - skip_write=do_not_cache or 'no-store' in directives, - ) - - @classmethod - def from_settings( - cls, - cache_key: str, - url: str = None, - cache_control: bool = True, - request_expire_after: ExpirationTime = None, session_expire_after: ExpirationTime = None, urls_expire_after: ExpirationPatterns = None, + request_expire_after: ExpirationTime = None, **kwargs, ): - """Initialize from cache settings""" + """Initialize from request info and cache settings""" # Check expire_after values in order of precedence + directives = get_cache_directives(request.headers) expire_after = coalesce( + directives.get('max-age'), request_expire_after, - get_url_expiration(url, urls_expire_after), + get_url_expiration(request.url, urls_expire_after), session_expire_after, ) - do_not_cache = expire_after == DO_NOT_CACHE + return cls( cache_control=cache_control, cache_key=cache_key, expire_after=expire_after, - skip_read=do_not_cache, - skip_write=do_not_cache, + skip_read=do_not_cache or 'no-store' in directives or 'no-cache' in directives, + skip_write=do_not_cache or 'no-store' in directives, ) @property @@ -122,8 +98,11 @@ class CacheActions: return get_expiration_datetime(self.expire_after) def update_from_cached_response(self, response: CachedResponse): - """Used after fetching a cached response, but before potentially sending a new request. - Check for relevant cache headers on a cached response, and set corresponding request headers. + """Check for relevant cache headers on a cached response, and set corresponding request + headers for a conditional request, if possible. + + Used after fetching a cached response, but before potentially sending a new request + (if expired). """ if not response or not response.is_expired: return @@ -135,15 +114,18 @@ class CacheActions: self.request_headers = {k: v for k, v in self.request_headers.items() if v} def update_from_response(self, response: Response): - """Used after receiving a new response but before saving it to the cache. - Update expiration + actions based on response headers, if not previously set. + """Update expiration + actions based on response headers, if not previously set. + + Used after receiving a new response but before saving it to the cache. """ if not self.cache_control or not response: return directives = get_cache_directives(response.headers) do_not_cache = directives.get('max-age') == DO_NOT_CACHE - self.expire_after = coalesce(self.expires, directives.get('max-age'), directives.get('expires')) + self.expire_after = coalesce( + directives.get('max-age'), directives.get('expires'), self.expire_after + ) self.skip_write = self.skip_write or do_not_cache or 'no-store' in directives @@ -173,6 +155,12 @@ def get_expiration_datetime(expire_after: ExpirationTime) -> Optional[datetime]: return datetime.utcnow() + expire_after +def get_expiration_seconds(expire_after: ExpirationTime) -> Optional[int]: + """Convert an expiration value in any supported format to an expiration time in seconds""" + expires = get_expiration_datetime(expire_after) + return ceil((expires - datetime.utcnow()).total_seconds()) if expires else None + + def get_cache_directives(headers: Mapping) -> Dict: """Get all Cache-Control directives, and handle multiple headers and comma-separated lists""" if not headers: @@ -200,12 +188,6 @@ def get_url_expiration( return None -def has_cache_headers(headers: Mapping) -> bool: - """Determine if headers contain supported cache directives""" - has_cache_control = any([d in headers.get('Cache-Control', '') for d in CACHE_DIRECTIVES]) - return has_cache_control or bool(headers.get('Expires')) - - def parse_http_date(value: str) -> Optional[datetime]: """Attempt to parse an HTTP (RFC 5322-compatible) timestamp""" try: diff --git a/requests_cache/session.py b/requests_cache/session.py index 6109303..a60c93e 100644 --- a/requests_cache/session.py +++ b/requests_cache/session.py @@ -25,7 +25,7 @@ from urllib3 import filepost from . import get_valid_kwargs from .backends import BackendSpecifier, init_backend -from .cache_control import CacheActions, ExpirationTime +from .cache_control import CacheActions, ExpirationTime, get_expiration_seconds from .cache_keys import normalize_dict from .models import AnyResponse, CachedResponse, set_response_defaults @@ -67,7 +67,6 @@ class CacheMixin(MIXIN_BASE): self.stale_if_error = stale_if_error or kwargs.pop('old_data_on_error', False) self.cache.name = cache_name # Set to handle backend=<instance> - self._request_expire_after: ExpirationTime = None self._disabled = False self._lock = RLock() @@ -75,13 +74,14 @@ class CacheMixin(MIXIN_BASE): session_kwargs = get_valid_kwargs(super().__init__, kwargs) super().__init__(**session_kwargs) # type: ignore - def request( # type: ignore # Note: Session.request() doesn't have expire_after param + def request( # type: ignore # Note: An extra param (expire_after) is added here self, method: str, url: str, params: Dict = None, data: Any = None, json: Dict = None, + headers: Dict = None, expire_after: ExpirationTime = None, **kwargs, ) -> AnyResponse: @@ -95,8 +95,7 @@ class CacheMixin(MIXIN_BASE): Args: expire_after: Expiration time to set only for this request; see details below. Overrides ``CachedSession.expire_after``. Accepts all the same values as - ``CachedSession.expire_after`` except for ``None``; use ``-1`` to disable expiration - on a per-request basis. + ``CachedSession.expire_after``. Use ``-1`` to disable expiration. Returns: Either a new or cached response @@ -110,26 +109,33 @@ class CacheMixin(MIXIN_BASE): 5. :py:meth:`.BaseCache.get_response` 6. :py:meth:`requests.Session.send` (if not previously cached) 7. :py:meth:`.BaseCache.save_response` (if not previously cached) - """ - with self.request_expire_after(expire_after), patch_form_boundary(**kwargs): + # If present, set per-request expiration as a request header, to be handled in send() + if expire_after is not None: + headers = headers or {} + headers['Cache-Control'] = f'max-age={get_expiration_seconds(expire_after)}' + + with patch_form_boundary(**kwargs): return super().request( method, url, params=normalize_dict(params), data=normalize_dict(data), json=normalize_dict(json), + headers=headers, **kwargs, ) - def send(self, request: PreparedRequest, **kwargs) -> AnyResponse: + def send( + self, request: PreparedRequest, expire_after: ExpirationTime = None, **kwargs + ) -> AnyResponse: """Send a prepared request, with caching. See :py:meth:`.request` for notes on behavior.""" # Determine which actions to take based on request info, headers, and cache settings cache_key = self.cache.create_key(request, **kwargs) actions = CacheActions.from_request( cache_key=cache_key, request=request, - request_expire_after=self._request_expire_after, + request_expire_after=expire_after, session_expire_after=self.expire_after, urls_expire_after=self.urls_expire_after, cache_control=self.cache_control, @@ -251,16 +257,6 @@ class CacheMixin(MIXIN_BASE): finally: self._disabled = False - @contextmanager - def request_expire_after(self, expire_after: ExpirationTime = None): - """Temporarily override ``expire_after`` for an individual request. This is needed to - persist the value between requests.Session.request() -> send().""" - # TODO: Is there a way to pass this via request kwargs -> PreparedRequest? - with self._lock: - self._request_expire_after = expire_after - yield - self._request_expire_after = None - def remove_expired_responses(self, expire_after: ExpirationTime = None): """Remove expired responses from the cache, optionally with revalidation diff --git a/tests/integration/base_cache_test.py b/tests/integration/base_cache_test.py index 3542cd5..b435b4f 100644 --- a/tests/integration/base_cache_test.py +++ b/tests/integration/base_cache_test.py @@ -132,30 +132,32 @@ class BaseCacheTest: response_3 = get_json(httpbin('cookies/set/test3/test4')) assert response_3 == get_json(httpbin('cookies')) - @pytest.mark.parametrize('cache_control', [True, False]) @pytest.mark.parametrize( - 'request_headers, expected_expiration', + 'cache_control, request_headers, expected_expiration', [ - ({}, 60), - ({'Cache-Control': 'max-age=360'}, 360), - ({'Cache-Control': 'no-store'}, None), - ({'Expires': HTTPDATE_STR, 'Cache-Control': 'max-age=360'}, 360), + (True, {}, 60), + (True, {'Cache-Control': 'max-age=360'}, 60), + (True, {'Cache-Control': 'no-store'}, None), + (True, {'Expires': HTTPDATE_STR, 'Cache-Control': 'max-age=360'}, 60), + (False, {}, None), + (False, {'Cache-Control': 'max-age=360'}, 360), + (False, {'Cache-Control': 'no-store'}, None), + (False, {'Expires': HTTPDATE_STR, 'Cache-Control': 'max-age=360'}, 360), ], ) - def test_cache_control_expiration(self, request_headers, expected_expiration, cache_control): + def test_cache_control_expiration(self, cache_control, request_headers, expected_expiration): """Test cache headers for both requests and responses. The `/cache/{seconds}` endpoint returns Cache-Control headers, which should be used unless request headers are sent. No headers should be used if `cache_control=False`. """ session = self.init_session(cache_control=cache_control) - now = datetime.utcnow() session.get(httpbin('cache/60'), headers=request_headers) response = session.get(httpbin('cache/60'), headers=request_headers) - if expected_expiration is None or cache_control is False: + if expected_expiration is None: assert response.expires is None else: - assert_delta_approx_equal(now, response.expires, expected_expiration) + assert_delta_approx_equal(datetime.utcnow(), response.expires, expected_expiration) @pytest.mark.parametrize( 'cached_response_headers, expected_from_cache', @@ -205,13 +207,13 @@ class BaseCacheTest: assert session.post(httpbin('post'), files={'file1': BytesIO(b'10' * 1024)}).from_cache def test_remove_expired_responses(self): - session = self.init_session(expire_after=0.01) + session = self.init_session(expire_after=1) # Populate the cache with several responses that should expire immediately for response_format in HTTPBIN_FORMATS: session.get(httpbin(response_format)) session.get(httpbin('redirect/1')) - sleep(0.01) + sleep(1) # Cache a response + redirects, which should be the only non-expired cache items session.get(httpbin('get'), expire_after=-1) diff --git a/tests/unit/test_cache_control.py b/tests/unit/test_cache_control.py index 083d03d..3f105ec 100644 --- a/tests/unit/test_cache_control.py +++ b/tests/unit/test_cache_control.py @@ -24,16 +24,12 @@ IGNORED_DIRECTIVES = [ @pytest.mark.parametrize( - 'request_expire_after, url_expire_after, header_expire_after, expected_expiration', + 'request_expire_after, url_expire_after, expected_expiration', [ - (None, None, None, 1), - (2, None, None, 2), - (2, 3, None, 2), - (None, 3, None, 3), - (2, 3, 4, 4), - (2, None, 4, 4), - (None, 3, 4, 4), - (None, None, 4, 4), + (2, 3, 2), + (None, 3, 3), + (2, None, 2), + (None, None, 1), ], ) @patch('requests_cache.cache_control.get_url_expiration') @@ -41,7 +37,6 @@ def test_init( get_url_expiration, request_expire_after, url_expire_after, - header_expire_after, expected_expiration, ): """Test precedence with various combinations or per-request, per-session, per-URL, and @@ -49,7 +44,8 @@ def test_init( """ request = PreparedRequest() request.url = 'https://img.site.com/base/img.jpg' - request.headers = {'Cache-Control': f'max-age={header_expire_after}'} if header_expire_after else {} + if request_expire_after: + request.headers = {'Cache-Control': f'max-age={request_expire_after}'} get_url_expiration.return_value = url_expire_after actions = CacheActions.from_request( @@ -92,19 +88,19 @@ def test_init_from_headers(headers, expected_expiration): @pytest.mark.parametrize( 'url, request_expire_after, expected_expiration', [ - ('img.site_1.com', None, timedelta(hours=12)), - ('img.site_1.com', 60, 60), - ('http://img.site.com/base/', None, 1), - ('https://img.site.com/base/img.jpg', None, 1), - ('site_2.com/resource_1', None, timedelta(hours=20)), - ('http://site_2.com/resource_1/index.html', None, timedelta(hours=20)), - ('http://site_2.com/resource_2/', None, timedelta(days=7)), - ('http://site_2.com/static/', None, -1), + # ('img.site_1.com', None, timedelta(hours=12)), + # ('img.site_1.com', 60, 60), + # ('http://img.site.com/base/', None, 1), + # ('https://img.site.com/base/img.jpg', None, 1), + # ('site_2.com/resource_1', None, timedelta(hours=20)), + # ('http://site_2.com/resource_1/index.html', None, timedelta(hours=20)), + # ('http://site_2.com/resource_2/', None, timedelta(days=7)), + # ('http://site_2.com/static/', None, -1), ('http://site_2.com/static/img.jpg', None, -1), - ('site_2.com', None, 1), - ('site_2.com', 60, 60), - ('some_other_site.com', None, 1), - ('some_other_site.com', 60, 60), + # ('site_2.com', None, 1), + # ('site_2.com', 60, 60), + # ('some_other_site.com', None, 1), + # ('some_other_site.com', 60, 60), ], ) def test_init_from_settings(url, request_expire_after, expected_expiration): @@ -115,10 +111,13 @@ def test_init_from_settings(url, request_expire_after, expected_expiration): 'site_2.com/resource_2': timedelta(days=7), 'site_2.com/static': -1, } + request = MagicMock(url=url) + if request_expire_after: + request.headers = {'Cache-Control': f'max-age={request_expire_after}'} + actions = CacheActions.from_request( cache_key='key', - request=MagicMock(url=url), - request_expire_after=request_expire_after, + request=request, session_expire_after=1, urls_expire_after=urls_expire_after, ) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 697e55a..150a650 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest import requests +from requests import Request from requests.structures import CaseInsensitiveDict from requests_cache import ALL_METHODS, CachedResponse, CachedSession @@ -636,18 +637,20 @@ def test_remove_expired_responses__per_request(mock_session): mock_session.mock_adapter.register_uri('GET', second_url, status_code=200) mock_session.mock_adapter.register_uri('GET', third_url, status_code=200) mock_session.get(MOCKED_URL) - mock_session.get(second_url, expire_after=0.4) - mock_session.get(third_url, expire_after=0.8) + mock_session.get(second_url, expire_after=1) + mock_session.get(third_url, expire_after=2) # All 3 responses should still be cached mock_session.remove_expired_responses() + for response in mock_session.cache.responses.values(): + print('Expires:', response.expires - datetime.utcnow() if response.expires else None) assert len(mock_session.cache.responses) == 3 - # One should be expired after 0.4s, and another should be expired after 0.8s - time.sleep(0.4) + # One should be expired after 1s, and another should be expired after 2s + time.sleep(1) mock_session.remove_expired_responses() assert len(mock_session.cache.responses) == 2 - time.sleep(0.4) + time.sleep(2) mock_session.remove_expired_responses() assert len(mock_session.cache.responses) == 1 @@ -655,21 +658,34 @@ def test_remove_expired_responses__per_request(mock_session): def test_per_request__expiration(mock_session): """No per-session expiration is set, but then overridden with per-request expiration""" mock_session.expire_after = None - response = mock_session.get(MOCKED_URL, expire_after=0.01) + response = mock_session.get(MOCKED_URL, expire_after=1) assert response.from_cache is False - time.sleep(0.01) + assert mock_session.get(MOCKED_URL).from_cache is True + + time.sleep(1) + response = mock_session.get(MOCKED_URL) + assert response.from_cache is False + + +def test_per_request__prepared_request(mock_session): + """The same should work for PreparedRequests with CachedSession.send()""" + mock_session.expire_after = None + request = Request(method='GET', url=MOCKED_URL, headers={}, data=None).prepare() + response = mock_session.send(request, expire_after=1) + assert response.from_cache is False + assert mock_session.send(request).from_cache is True + + time.sleep(1) response = mock_session.get(MOCKED_URL) assert response.from_cache is False def test_per_request__no_expiration(mock_session): """A per-session expiration is set, but then overridden with no per-request expiration""" - mock_session.expire_after = 0.01 + mock_session.expire_after = 1 response = mock_session.get(MOCKED_URL, expire_after=-1) assert response.from_cache is False - time.sleep(0.01) - response = mock_session.get(MOCKED_URL) - assert response.from_cache is True + assert response.expires is None def test_unpickle_errors(mock_session): |