mangools
Python client for the Mangools API — KWFinder, SERPChecker,
SERPWatcher, LinkMiner, SiteProfiler and AI Search Watcher. 82 operations, 184 models, httpx-based,
fully typed, py.typed.
Early access, generated client — feedback welcome. Every line under
mangools/is generated from the OpenAPI document committed in this repo (openapi.json); nothing is hand-written. The0.xseries will change shape as the spec is completed, and some responses are still untyped because the spec does not describe them yet — see Known gaps. Please report anything that surprises you.
Install
pip install mangools
Python 3.9 or newer. Runtime dependencies: httpx, attrs, python-dateutil.
Hello world
import os
from mangools import MangoolsClient
from mangools.api.aisearchwatcher import get_aiwatcher_monitors
from mangools.api.kwfinder import get_kwfinder_related_keywords
from mangools.models import Error
client = MangoolsClient(api_key=os.environ["MANGOOLS_API_KEY"])
related = get_kwfinder_related_keywords.sync(client=client, kw="seo tools")
if isinstance(related, Error):
raise SystemExit(f"{related.error.type_}: {related.error.message}")
if related is not None and related.keywords:
print(f"related keywords ({related.count_keywords_before_limit} before the limit):")
for keyword in related.keywords[:5]:
print(f" {keyword.kw!r:<28} sv={keyword.sv} cpc={keyword.cpc} seo={keyword.seo}")
monitors = get_aiwatcher_monitors.sync(client=client)
if isinstance(monitors, Error):
raise SystemExit(f"{monitors.error.type_}: {monitors.error.message}")
if monitors is not None and monitors.monitors:
print(f"\n{len(monitors.monitors)} AI Search Watcher monitors:")
for monitor in monitors.monitors:
print(f" {monitor.field_id} {monitor.brand!r} -> {monitor.domain}")
get_kwfinder_related_keywords also takes location_id and language_id; both default to 0
(worldwide / all languages). Resolve real values with
mangools.api.kwfinder.get_mangools_locations.
Authentication
Mangools authenticates with an API key in the x-access-token header. It is not an RFC 6750
bearer token, so Authorization: Bearer … will not work.
from mangools import MangoolsClient
client = MangoolsClient(api_key=os.environ["MANGOOLS_API_KEY"])
MangoolsClient is a thin factory over the generated AuthenticatedClient that fills in the header
name from the spec's ApiKeyAuth scheme. Keyword arguments are forwarded verbatim, so
timeout=httpx.Timeout(30.0), headers={...}, follow_redirects=True, verify_ssl=False and
httpx_args={...} all work:
import httpx
client = MangoolsClient(
api_key=os.environ["MANGOOLS_API_KEY"],
timeout=httpx.Timeout(30.0),
raise_on_unexpected_status=True,
)
base_url defaults to the spec's servers[0].url, https://api.mangools.com/v3. Override it to
point at a sandbox.
Never hard-code the key. Read it from the environment or a secret manager.
Calling an endpoint
Every operation is a module under mangools.api.<tag> exposing four functions:
| function | returns | on an undocumented status |
|---|---|---|
sync(...) |
the parsed body, or None |
None (or raises if raise_on_unexpected_status) |
sync_detailed(...) |
Response[T] with status_code, headers, content, parsed |
same |
asyncio(...) |
awaitable parsed body | same |
asyncio_detailed(...) |
awaitable Response[T] |
same |
import asyncio
from mangools.api.serpwatcher import get_serpwatcher_trackings
async def main() -> None:
response = await get_serpwatcher_trackings.asyncio_detailed(client=client)
print(response.status_code, response.parsed)
asyncio.run(main())
Use sync_detailed / asyncio_detailed when you need the status code or headers — for example the
Retry-After on a 429.
Optional fields are Unset, not None, because the API distinguishes "absent" from "explicitly
null". Unset is falsy and narrows correctly under mypy:
from mangools.types import UNSET, Unset
if keyword.sv is not UNSET:
... # keyword.sv is an int here
Errors
Most non-2xx responses parse into the Error envelope, so error handling is a type check rather
than a status-code table:
from mangools.models import Error
result = get_kwfinder_related_keywords.sync(client=client, kw="seo tools")
if isinstance(result, Error):
print(result.error.type_, result.error.message)
if result.error.retry_after is not UNSET:
print("retry after", result.error.retry_after, "s")
error.errors carries the Joi messages on a 422 and error.retry_after the wait in seconds on a 429.
A status the spec does not document raises mangools.errors.UnexpectedStatus when the client is built
with raise_on_unexpected_status=True.
Two 429s exist and they do not look alike. The application's own 429 is the Error envelope above.
The gateway's is produced by nginx's limit_req before the request reaches the application, so its
body is nginx's HTML error page; the spec declares it as text/html, and the operations that carry
it gain a str arm rather than an Error arm:
from mangools.api.kwfinder import get_kwfinder_limits
limits = get_kwfinder_limits.sync(client=client) # Union[Limit, str] | None
if isinstance(limits, str):
... # the gateway HTML page: 4 req/s per client IP was exceeded
GET /kwfinder/limits is also the exception to the Error type check. It accepts an anonymous
caller and has no validation or quota gate, so there is no Error branch to check for at all — an
isinstance(result, Error) there is dead code.
Typing
The package ships a PEP 561 py.typed marker, so mypy and pyright type-check your call sites
with no stub package. The generated client itself passes mypy --strict with no type: ignore
anywhere.
Regenerating
The client is a pure function of openapi.json plus the pinned toolchain:
pip install -r requirements-dev.txt
./scripts/regenerate.sh
CI runs the same script and fails if the working tree moves, so mangools/ is never edited by hand.
scripts/regenerate.sh also rewrites SPEC_GAPS.md and TYPE_GAPS.md, which means a spec change
that closes a gap has to land the updated report in the same commit.
openapi-python-client is used rather than OpenAPI Generator: OpenAPI Generator's python generator
offers only asyncio, tornado and urllib3 transports, none of them httpx.
Spec pin
| spec document | openapi.json, committed verbatim in this repo |
| spec release | v1.1.0 in mangools/api-spec |
info.version |
3.0.0 |
| sha256 | 63864af3095f2e171ab8e2ee154a8282aa956c83770b1957c5c68ae4cc004f3f |
| previous sha256 | ee00faf91df54a1e74fb12be34bb77055f9a60a945047e753a0451d8e3857370 |
| generator | openapi-python-client==0.26.2 |
No operation moved between those two documents: the same 82 under the same operationIds at the
same paths, so no generated function was renamed and no call site has to change. v1.1.0 gives the
four free-form metric maps a value schema and gives the 404 of
GET /mangools/locations/{location} the Error body it always should have had.
Typing the two RankDist maps added six models (178 → 184), all of them new names nothing
referred to before:
RankDistRankAdditionalProperty RankDistVisibilityAdditionalProperty
RankDistRankAdditionalPropertyOrganicItem RankDistVisibilityAdditionalPropertyOrganic
RankDistRankAdditionalPropertyPaidItem RankDistVisibilityAdditionalPropertyPaid
Where you read dict[str, Any] out of RankDist.rank or .visibility before, you now read a
typed object. That is the one place a caller written against v1.0.0 sees a difference, and it is
a widening of what the type system knows, not of what the API returns.
The first published spec did move four operations. If you are upgrading from it rather than from the previous pin, correcting paths the API does not serve renamed four modules:
| was | is |
|---|---|
post_kwfinder_lists_by_list_id_keyword |
post_kwfinder_lists_by_list_id_keywords |
delete_kwfinder_lists_by_list_id_keyword |
delete_kwfinder_lists_by_list_id_keywords |
put_serpwatcher_trackings_by_tracking_id |
patch_serpwatcher_trackings_by_tracking_id |
delete_serpwatcher_trackings_by_tracking_id_tags |
delete_serpwatcher_trackings_by_tracking_id_tags_by_tag_id (the tag is a path parameter, not a body field) |
POST /serpwatcher/trackings/{tracking_id}/stats also moved kwIds from the query string into the
request body, where the API reads it: the signature is now
sync(tracking_id, *, client, body=PostSerpwatcherTrackingsByTrackingIdStatsBody(...), from_=…, to=…).
Nothing is aliased; these five call sites have to be updated.
The bytes in openapi.json are the pin — the SDK cannot describe an endpoint the committed spec
does not contain. The same document is tagged v1.1.0 in mangools/api-spec, which is private for
now; the sha256 above is what identifies the pin from outside that repository.
Known gaps
Two generated reports track everything the spec does not yet say:
SPEC_GAPS.md— the full audit ofopenapi.json: 0 blockers, 0 bugs, 1 gap, with a JSON Pointer and the concrete schema that would fix it. It is the description-onlyallOfmember onSPMetrics.fb, which takes nothing away from the caller.TYPE_GAPS.md— 0 blocking positions. No operation returnsAnyand no model attribute is typedAny. Under specv1.0.0this was 1:GET /mangools/locations/{location}declared a404with nocontentand widened the parsed union toUnion[Any, Location, str].
The four metric maps are typed as of v1.1.0
MozMetric.v, RankDist.rank, RankDist.visibility and RankDist.metrics_absolute.*.organic were
free-form objects under spec v1.0.0 and reached you as dict[str, Any]. They now carry value
schemas: MozMetric.v from 2000 sampled url_metrics documents and the Mozscape column bitmask,
the two RankDist maps from their month buckets, and organic from the 20 DataForSEO columns that
parseDomainStats writes.
RankDist.metrics_absolute.*.organic stays an open map on purpose, with a typed value: the provider
chooses which columns it returns, so the column set is not the contract but the value type is.
Both gates are green on this pin. mypy --strict passes on 279 files and
scripts/check_spec_any.py reports zero spec-derived Any. That gate stays in CI rather than being
retired, because it is what would catch the next schema that arrives untyped.
Metric groups: absent, not null
An optional metric group is omitted from the response until its provider has been queried — the
API builds it with a lookup that yields undefined, and res.json drops the key. The attribute is
therefore Union[Unset, T]: check is not UNSET, not is not None.
SPMetrics.fb is the one exception. Its producer defaults the lookup to null, so it really can
arrive as JSON null, and it is typed Union[SPMetricsFbType0, None, Unset] — three states, all
reachable:
from mangools.api.siteprofiler import get_siteprofiler_overview
overview = get_siteprofiler_overview.sync(client=client, url_query="mangools.com")
if overview is not None and not isinstance(overview, Error):
if overview.fb is UNSET:
... # the field was not in the response
elif overview.fb is None:
... # Facebook data has never been fetched for this domain
else:
... # overview.fb.l is the engagement count
Base URL and the AI Search Watcher paths
The epic flags a possible /v3/ double prefix on AI Search Watcher operations. It does not occur in
this spec: no path in openapi.json carries a /v3 prefix, AI Search Watcher paths are
/aiwatcher/… like every other tag, and servers[0].url already ends in /v3. The generated client
therefore requests https://api.mangools.com/v3/aiwatcher/monitors, which is the route the API
serves. Nothing needs to be worked around; documentation that writes the path as /v3/aiwatcher/…
is quoting the full URL rather than a spec path.
Releasing
The package is prepared but has never been uploaded; the mangools name on PyPI is free. Publishing
is a deliberate human step:
pip install -r requirements-dev.txt
python -m build # writes dist/mangools-<version>.{tar.gz,whl}
twine check --strict dist/*
twine upload dist/* # the one command that publishes
To bump the version, edit package_version_override in openapi-python-client.yaml and run
./scripts/regenerate.sh. That is the only place the version is written by hand: regeneration
renders it into mangools/__init__.py, and hatchling reads it back out of there. Editing
__init__.py directly fails the regenerate-and-diff gate.
License
Apache-2.0. See LICENSE.
Release files for mangools 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mangools-0.1.0.tar.gz | 171.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mangools-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 496.3 kB
Release files / mangools-0.1.0.tar.gz
| Download URL | mangools-0.1.0.tar.gz |
|---|---|
| Size | 171.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3387f251de54832162ddbaded379c46d5b23500da66aa4a3e998de7797a8f524
|
|
BLAKE2b-256 checksum How to use checksums |
bc343d80b9b417754b9782e29226b36c0cf513b7045f7e38a0fb50d26c615965
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / mangools-0.1.0-py3-none-any.whl
| Download URL | mangools-0.1.0-py3-none-any.whl |
|---|---|
| Size | 325.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
108d941436b7f12fa0f1497e04fef59bc0c14d5404f29caa10e8a33026b2b669
|
|
BLAKE2b-256 checksum How to use checksums |
baa0688d7fde6922aefa74f4c34351206c33e1db5ef74277c14fed1ac7b9b326
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|