googleapis-without-429 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. googleapis_without_429-0.1.0/.github/workflows/ci.yml +66 -0
  2. googleapis_without_429-0.1.0/.github/workflows/release.yml +101 -0
  3. googleapis_without_429-0.1.0/.gitignore +24 -0
  4. googleapis_without_429-0.1.0/.pre-commit-config.yaml +30 -0
  5. googleapis_without_429-0.1.0/CHANGELOG.md +41 -0
  6. googleapis_without_429-0.1.0/LICENSE +21 -0
  7. googleapis_without_429-0.1.0/Makefile +32 -0
  8. googleapis_without_429-0.1.0/PKG-INFO +250 -0
  9. googleapis_without_429-0.1.0/README.md +222 -0
  10. googleapis_without_429-0.1.0/pyproject.toml +189 -0
  11. googleapis_without_429-0.1.0/scripts/check_sdist.py +73 -0
  12. googleapis_without_429-0.1.0/src/googleapis_without_429/__init__.py +23 -0
  13. googleapis_without_429-0.1.0/src/googleapis_without_429/backoff.py +88 -0
  14. googleapis_without_429-0.1.0/src/googleapis_without_429/core.py +139 -0
  15. googleapis_without_429-0.1.0/src/googleapis_without_429/limiter.py +143 -0
  16. googleapis_without_429-0.1.0/src/googleapis_without_429/profiles.py +252 -0
  17. googleapis_without_429-0.1.0/src/googleapis_without_429/py.typed +0 -0
  18. googleapis_without_429-0.1.0/src/googleapis_without_429/session.py +123 -0
  19. googleapis_without_429-0.1.0/tests/__init__.py +0 -0
  20. googleapis_without_429-0.1.0/tests/conftest.py +34 -0
  21. googleapis_without_429-0.1.0/tests/test_backoff.py +87 -0
  22. googleapis_without_429-0.1.0/tests/test_core.py +186 -0
  23. googleapis_without_429-0.1.0/tests/test_gspread_integration.py +180 -0
  24. googleapis_without_429-0.1.0/tests/test_limiter.py +159 -0
  25. googleapis_without_429-0.1.0/tests/test_profiles.py +272 -0
  26. googleapis_without_429-0.1.0/tests/test_readme.py +149 -0
  27. googleapis_without_429-0.1.0/tests/test_session.py +286 -0
@@ -0,0 +1,66 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ # Callable from the release workflow, so a tag runs the very same checks
8
+ # rather than a second copy of them that can drift.
9
+ workflow_call:
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ test:
17
+ name: tests (python ${{ matrix.python-version }})
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
23
+ env:
24
+ UV_PYTHON: ${{ matrix.python-version }}
25
+ steps:
26
+ - uses: actions/checkout@v7
27
+ # Pinned to the full version: this action publishes no major-tag alias.
28
+ - uses: astral-sh/setup-uv@v10.0.1
29
+ with:
30
+ enable-cache: true
31
+ - run: uv sync --locked
32
+ - run: make test
33
+
34
+ quality:
35
+ name: lint, format and types
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v7
39
+ # Pinned to the full version: this action publishes no major-tag alias.
40
+ - uses: astral-sh/setup-uv@v10.0.1
41
+ with:
42
+ enable-cache: true
43
+ - run: uv sync --locked
44
+ - run: make lint
45
+ - run: make check-format
46
+ - run: make typecheck
47
+
48
+ build:
49
+ name: package builds and installs
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v7
53
+ # Pinned to the full version: this action publishes no major-tag alias.
54
+ - uses: astral-sh/setup-uv@v10.0.1
55
+ with:
56
+ enable-cache: true
57
+ - run: uv build
58
+ # Nothing in git stops a file from reaching an artefact: internal notes
59
+ # are excluded through .git/info/exclude, which no build backend reads.
60
+ - run: uv run --no-sync python scripts/check_sdist.py
61
+ # Installing the built wheel into a clean environment is the only check
62
+ # that catches a file missing from the package but present on disk.
63
+ - name: install the built wheel into a clean environment
64
+ run: |
65
+ uv run --isolated --no-project --with dist/*.whl \
66
+ python -c "import googleapis_without_429 as m; print(m.__version__)"
@@ -0,0 +1,101 @@
1
+ name: Release
2
+
3
+ # Publishing uses PyPI Trusted Publishing: GitHub mints a short-lived OIDC
4
+ # token for this specific workflow in this specific repository, and PyPI
5
+ # verifies it. No API token is stored anywhere, so there is none to leak.
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+ workflow_dispatch:
10
+ inputs:
11
+ repository:
12
+ description: Index to publish to
13
+ type: choice
14
+ default: testpypi
15
+ options: [testpypi, pypi]
16
+
17
+ jobs:
18
+ checks:
19
+ uses: ./.github/workflows/ci.yml
20
+
21
+ build:
22
+ needs: checks
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v7
26
+ - uses: astral-sh/setup-uv@v10.0.1
27
+ with:
28
+ enable-cache: true
29
+
30
+ # A tag that disagrees with the packaged version publishes something
31
+ # nobody can find again. Cheap to check, expensive to discover later.
32
+ - name: Tag must match the packaged version
33
+ if: startsWith(github.ref, 'refs/tags/v')
34
+ run: |
35
+ tag="${GITHUB_REF_NAME#v}"
36
+ version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
37
+ if [ "$tag" != "$version" ]; then
38
+ echo "::error::tag v$tag does not match project version $version"
39
+ exit 1
40
+ fi
41
+ echo "tag and version agree: $version"
42
+
43
+ - run: uv build
44
+ - run: uv run --no-sync python scripts/check_sdist.py
45
+ - run: uv run --isolated --no-project --with twine twine check dist/*
46
+
47
+ - uses: actions/upload-artifact@v7
48
+ with:
49
+ name: distributions
50
+ path: dist/
51
+
52
+ publish-testpypi:
53
+ if: github.event_name == 'workflow_dispatch' && inputs.repository == 'testpypi'
54
+ needs: build
55
+ runs-on: ubuntu-latest
56
+ environment:
57
+ name: testpypi
58
+ url: https://test.pypi.org/p/googleapis-without-429
59
+ permissions:
60
+ id-token: write
61
+ steps:
62
+ - uses: actions/download-artifact@v8
63
+ with:
64
+ name: distributions
65
+ path: dist/
66
+ - uses: pypa/gh-action-pypi-publish@v1.14.2
67
+ with:
68
+ repository-url: https://test.pypi.org/legacy/
69
+
70
+ publish-pypi:
71
+ if: startsWith(github.ref, 'refs/tags/v') || inputs.repository == 'pypi'
72
+ needs: build
73
+ runs-on: ubuntu-latest
74
+ environment:
75
+ name: pypi
76
+ url: https://pypi.org/p/googleapis-without-429
77
+ permissions:
78
+ id-token: write
79
+ steps:
80
+ - uses: actions/download-artifact@v8
81
+ with:
82
+ name: distributions
83
+ path: dist/
84
+ - uses: pypa/gh-action-pypi-publish@v1.14.2
85
+
86
+ github-release:
87
+ if: startsWith(github.ref, 'refs/tags/v')
88
+ needs: publish-pypi
89
+ runs-on: ubuntu-latest
90
+ permissions:
91
+ contents: write
92
+ steps:
93
+ - uses: actions/checkout@v7
94
+ - uses: actions/download-artifact@v8
95
+ with:
96
+ name: distributions
97
+ path: dist/
98
+ - uses: softprops/action-gh-release@v3
99
+ with:
100
+ files: dist/*
101
+ generate_release_notes: true
@@ -0,0 +1,24 @@
1
+ # Build output
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+
6
+ # Bytecode
7
+ __pycache__/
8
+ *.py[cod]
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Tool caches and coverage
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ .coverage.*
20
+ htmlcov/
21
+ coverage.xml
22
+
23
+ # macOS writes these into every directory it opens
24
+ .DS_Store
@@ -0,0 +1,30 @@
1
+ # Runs on commit. The same checks run again in CI, because a hook can be
2
+ # skipped with --no-verify and a pipeline cannot.
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v6.0.0
6
+ hooks:
7
+ - id: trailing-whitespace
8
+ - id: end-of-file-fixer
9
+ - id: check-yaml
10
+ - id: check-toml
11
+ - id: check-merge-conflict
12
+ - id: check-added-large-files
13
+
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.16.6
16
+ hooks:
17
+ - id: ruff-check
18
+ args: [--fix]
19
+ - id: ruff-format
20
+
21
+ - repo: local
22
+ hooks:
23
+ - id: mypy
24
+ name: mypy
25
+ # --no-sync matters: a plain `uv run` reconciles uv.lock with
26
+ # pyproject.toml, and a hook that rewrites a file mid-commit fails.
27
+ entry: uv run --no-sync mypy
28
+ language: system
29
+ pass_filenames: false
30
+ types: [python]
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ Nothing yet.
11
+
12
+ ## [0.1.0] - 2026-09-09
13
+
14
+ ### Added
15
+
16
+ - `WeightedSlidingWindow`: thread-safe core limiter that counts cost rather
17
+ than calls, so one implementation covers both request-counted APIs (Sheets)
18
+ and quota-unit APIs (Gmail).
19
+ - `ApiProfile` and the `SHEETS` profile: per-API data describing the host, its
20
+ separate read and write quotas, and how a call maps to one of them. Limits
21
+ are overridable with `SHEETS.with_limits(read=300, write=300)`, since real
22
+ quotas depend on the project and Google revises them.
23
+ - `RateLimitedSession`: an `AuthorizedSession` that paces itself against a
24
+ profile's quotas and retries a 429 with equal-jitter backoff, honouring
25
+ `Retry-After` when the server sends one. Requests to hosts without a profile
26
+ pass through untouched, so token refreshes do not consume the API's quota.
27
+ Drop it into any client that accepts a session, such as gspread.
28
+ - `DRIVE` profile, covering the half of gspread that is not Sheets: creating,
29
+ deleting, sharing and finding a spreadsheet by title all go to the Drive API.
30
+ Drive meters weighted quota units in one shared bucket rather than counting
31
+ requests in two, so a listing costs twenty times a single item read.
32
+ - Profiles can claim path prefixes, since `www.googleapis.com` serves several
33
+ APIs and a host alone no longer identifies which quota applies.
34
+ - `QuotaLimiter`: the quota buckets on their own, for code that does not go
35
+ through a `requests` session. `limiter.limit(SHEETS, "write")` works both as
36
+ a context manager and as a decorator, and `limiter.bucket(...)` exposes the
37
+ underlying window for anything this library does not model. A session's own
38
+ buckets are reachable through `session.limiter`.
39
+
40
+ [Unreleased]: https://github.com/pavlosambur/googleapis-without-429/compare/v0.1.0...HEAD
41
+ [0.1.0]: https://github.com/pavlosambur/googleapis-without-429/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pavlo Sambur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,32 @@
1
+ # One definition of what "checked" means. CI calls these same targets, so a
2
+ # green local run and a green pipeline cannot drift apart.
3
+ .DEFAULT_GOAL := help
4
+ .PHONY: help install lint format check-format typecheck test package check
5
+
6
+ help: ## Show the available targets
7
+ @grep -E '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \
8
+ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
9
+
10
+ install: ## Install the project and its development dependencies
11
+ uv sync
12
+
13
+ lint: ## Report lint violations
14
+ uv run ruff check .
15
+
16
+ format: ## Reformat the code in place
17
+ uv run ruff format .
18
+
19
+ check-format: ## Verify formatting without changing anything
20
+ uv run ruff format --check .
21
+
22
+ typecheck: ## Run the type checker
23
+ uv run mypy
24
+
25
+ test: ## Run the test suite with coverage
26
+ uv run pytest --cov
27
+
28
+ package: ## Build the distributions and check what they contain
29
+ uv build
30
+ uv run --no-sync python scripts/check_sdist.py
31
+
32
+ check: lint check-format typecheck test package ## Everything the pipeline runs
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.5
2
+ Name: googleapis-without-429
3
+ Version: 0.1.0
4
+ Summary: Drop-in transport for Google API clients that stays inside the quota instead of recovering from 429.
5
+ Project-URL: Homepage, https://github.com/pavlosambur/googleapis-without-429
6
+ Project-URL: Repository, https://github.com/pavlosambur/googleapis-without-429
7
+ Project-URL: Issues, https://github.com/pavlosambur/googleapis-without-429/issues
8
+ Project-URL: Changelog, https://github.com/pavlosambur/googleapis-without-429/blob/main/CHANGELOG.md
9
+ Author-email: Pavlo Sambur <samburp@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: 429,google-api,google-sheets,gspread,quota,rate-limiting
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: google-auth>=2.0
26
+ Requires-Dist: requests>=2.31
27
+ Description-Content-Type: text/markdown
28
+
29
+ # googleapis-without-429
30
+
31
+ [![CI](https://github.com/pavlosambur/googleapis-without-429/actions/workflows/ci.yml/badge.svg)](https://github.com/pavlosambur/googleapis-without-429/actions/workflows/ci.yml)
32
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/pavlosambur/googleapis-without-429)
33
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
34
+
35
+ Stay inside Google API quotas instead of recovering from `429 Too many
36
+ requests`. One argument, and the rest of your code is unchanged.
37
+
38
+ ```python
39
+ import gspread
40
+ from googleapis_without_429 import RateLimitedSession
41
+
42
+ session = RateLimitedSession(credentials) # <- the only change
43
+ gc = gspread.authorize(credentials, session=session)
44
+
45
+ sheet = gc.open("My Sheet").sheet1
46
+ for row in rows:
47
+ sheet.append_row(row) # waits when the quota is spent, then continues
48
+ ```
49
+
50
+ No decorators to add, no calls to rewrite, no `sleep()` sprinkled through the
51
+ loop. The session knows what Google's quotas are and paces itself.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install googleapis-without-429
57
+ ```
58
+
59
+ Requires Python 3.10 or newer.
60
+
61
+ ## The problem
62
+
63
+ Google's per-minute quotas are small. Sheets allows **60 reads and 60 writes
64
+ per minute per user** — a loop that appends rows hits that in a minute of
65
+ ordinary work, and the script dies partway through with half the data written.
66
+
67
+ The official advice is exponential backoff, and every retry library implements
68
+ it. But backoff is a reaction *after* the failure: it recovers, it does not
69
+ prevent. The better first move is not to exceed the quota at all, and to keep
70
+ retries as the second line of defence.
71
+
72
+ That is what this does. It tracks what you have spent against a sliding window
73
+ and blocks the call that would go over, instead of letting Google reject it.
74
+
75
+ ## What is covered
76
+
77
+ | API | Quota | How it is metered |
78
+ |---|---|---|
79
+ | Sheets | 60 reads + 60 writes per minute | separate buckets; every call costs 1, batches included |
80
+ | Drive | 325,000 quota units per minute | one shared bucket; a call costs 5 to 200 units |
81
+
82
+ Together these cover [gspread](https://github.com/burnash/gspread) completely —
83
+ which needs both, since Sheets moves the cell data while Drive owns the file:
84
+
85
+ | gspread call | Goes to |
86
+ |---|---|
87
+ | `open_by_key`, `open_by_url` | Sheets |
88
+ | `worksheet.get`, `get_all_values`, `batch_get` | Sheets |
89
+ | `append_row`, `update`, `clear`, `batch_update` | Sheets |
90
+ | `open("title")`, `openall`, `list_spreadsheet_files` | Drive, then Sheets |
91
+ | `create`, `copy`, `del_spreadsheet`, `share` | Drive |
92
+
93
+ Nothing else is covered yet: Gmail, Calendar and Docs have no profile, and
94
+ `google-api-python-client` uses a different transport. Both are on the roadmap.
95
+ A request to any host without a profile passes through untouched — including
96
+ the token refresh your credentials perform, which must not eat the quota of the
97
+ API you are actually calling.
98
+
99
+ ## This does not remove the need for retries
100
+
101
+ It reduces 429s. It does not eliminate them, and any library claiming otherwise
102
+ is overselling.
103
+
104
+ The reason is that the two sides count differently. This library slides a
105
+ window over the timestamps of *your* calls. Google meters *fixed* windows whose
106
+ boundaries you cannot see. So 60 calls that look perfectly spaced from here can
107
+ land as 30 in the tail of one of Google's minutes and 30 in the head of the
108
+ next — and the second batch is over the limit even though our counter says
109
+ there is room.
110
+
111
+ Being strict about our own window makes us conservative, never reckless: we may
112
+ allow fewer calls than Google would, never more. But the boundary mismatch is
113
+ real, so a retry on 429 is built in and on by default:
114
+
115
+ ```python
116
+ RateLimitedSession(
117
+ credentials,
118
+ max_attempts=5, # total tries per request, including the first
119
+ backoff_base=1.0, # ceiling for the first retry delay, in seconds
120
+ backoff_cap=60.0, # the ceiling stops doubling here
121
+ )
122
+ ```
123
+
124
+ Delays use equal jitter: half the ceiling is always waited and the rest is
125
+ randomised. The guaranteed half matters — a 429 means the window has not
126
+ reopened yet, so a delay that comes out near zero only buys another 429. A
127
+ `Retry-After` header, if the server sends one, wins over the computed delay.
128
+
129
+ ## Adjusting the limits
130
+
131
+ The shipped numbers are Google's documented defaults, and defaults go stale.
132
+ Real quotas depend on the project, on when it was created, and Google revises
133
+ them — Drive's changed on 1 May 2026, and projects already using the API kept
134
+ the previous ones. So overriding is a first-class operation:
135
+
136
+ ```python
137
+ from googleapis_without_429 import DRIVE, SHEETS, RateLimitedSession
138
+
139
+ session = RateLimitedSession(
140
+ credentials,
141
+ [
142
+ SHEETS.with_limits(read=300, write=300), # the per-project ceiling
143
+ DRIVE.with_limits(units=12_000), # an older project
144
+ ],
145
+ )
146
+ ```
147
+
148
+ The defaults are the **per-user** quotas, which is what a single script runs
149
+ into. Raise them to the per-project ceiling only if the job really is the only
150
+ thing using that project. A misspelled bucket name raises rather than being
151
+ quietly ignored, so a typo cannot leave you believing a limit was raised.
152
+
153
+ Check what your project actually has in the Cloud Console under
154
+ **APIs & Services → Quotas**; it can differ from the documentation.
155
+
156
+ ## Without a session
157
+
158
+ If the calls are not made through a `requests` session — a hand-rolled client,
159
+ a worker, an API this library has no adapter for — use the limiter directly. It
160
+ is both a context manager and a decorator:
161
+
162
+ ```python
163
+ from googleapis_without_429 import SHEETS, QuotaLimiter
164
+
165
+ limiter = QuotaLimiter([SHEETS])
166
+
167
+
168
+ @limiter.limit(SHEETS, "write")
169
+ def push_batch(rows): ...
170
+
171
+
172
+ with limiter.limit(SHEETS, "read"):
173
+ ...
174
+ ```
175
+
176
+ And the raw window underneath, when nothing above fits:
177
+
178
+ ```python
179
+ limiter.bucket(SHEETS, "write").acquire(cost=1)
180
+ limiter.bucket(SHEETS, "write").used # what is currently counted
181
+ ```
182
+
183
+ A session exposes its own limiter the same way, so you can pace a call it does
184
+ not make itself:
185
+
186
+ ```python
187
+ session.limiter.bucket(SHEETS, "read").acquire()
188
+ ```
189
+
190
+ ## Adding an API
191
+
192
+ A profile is data, not code: a host, a map of buckets to limits, and a function
193
+ that says which bucket a call belongs to and what it costs.
194
+
195
+ ```python
196
+ from googleapis_without_429 import ApiProfile, QuotaLimiter
197
+
198
+
199
+ def resolve_docs(http_method, path, query):
200
+ return ("read" if http_method == "GET" else "write"), 1
201
+
202
+
203
+ DOCS = ApiProfile(
204
+ name="docs",
205
+ host="docs.googleapis.com",
206
+ limits={"read": 300, "write": 60},
207
+ resolve=resolve_docs,
208
+ )
209
+
210
+ limiter = QuotaLimiter([DOCS])
211
+ ```
212
+
213
+ Two things worth knowing before writing one:
214
+
215
+ - **The HTTP method is not the whole story.** Sheets sends several reads as
216
+ POST (`:getByDataFilter`, `:batchGetByDataFilter`, `developerMetadata:search`)
217
+ because they carry a request body. Charging those to the write bucket burns
218
+ one of only 60 writes a minute. The published
219
+ [discovery document](https://developers.google.com/discovery/v1/reference)
220
+ for an API lists every method with its real HTTP verb and path.
221
+ - **A host may serve several APIs.** Drive lives on `www.googleapis.com`
222
+ alongside others, so its profile claims `path_prefixes=("/drive/",
223
+ "/upload/drive/")`. Leave that empty only when the host belongs to one API.
224
+
225
+ Profiles for other Google APIs are very welcome as pull requests — that is the
226
+ cheapest way for this library to grow, and it needs no changes to the core.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ uv sync # install the project and its dev dependencies
232
+ make check # everything CI runs: lint, format, types, tests
233
+ uv run pre-commit install # optional: fast checks on every commit
234
+ ```
235
+
236
+ Individual steps: `make lint`, `make check-format`, `make typecheck`,
237
+ `make test`. CI calls the same targets, so a green local run means a green
238
+ pipeline. The test suite needs no credentials and makes no network calls.
239
+
240
+ ## Roadmap
241
+
242
+ - An adapter for `google-api-python-client`, which uses an httplib2-style
243
+ transport rather than a `requests` session
244
+ - A Gmail profile — its quota units range from 2 to 100 per call, which is what
245
+ the weighted core was built for
246
+ - Async support
247
+
248
+ ## License
249
+
250
+ MIT