Metadata-Version: 2.4
Name: mangools
Version: 0.1.0
Summary: Generated Python client for the Mangools API (KWFinder, SERPChecker, SERPWatcher, LinkMiner, SiteProfiler, AI Search Watcher).
Project-URL: Homepage, https://mangools.com
Project-URL: Documentation, https://api.mangools.com/v3/docs
Project-URL: Changelog, https://github.com/mangools/sdk-python/blob/main/CHANGELOG.md
Author: Mangools
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: kwfinder,mangools,openapi,sdk,seo,serpwatcher
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: attrs>=22.2.0
Requires-Dist: httpx<0.29.0,>=0.23.0
Requires-Dist: python-dateutil<3,>=2.8.0
Description-Content-Type: text/markdown

# mangools

Python client for the [Mangools API](https://api.mangools.com/v3/docs) — 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. The `0.x` series 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](#known-gaps). Please report anything that surprises you.

## Install

```console
pip install mangools
```

Python 3.9 or newer. Runtime dependencies: `httpx`, `attrs`, `python-dateutil`.

## Hello world

```python
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.

```python
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:

```python
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 |

```python
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`:

```python
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:

```python
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:

```python
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:

```console
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 `operationId`s 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`](SPEC_GAPS.md)** — the full audit of `openapi.json`: 0 blockers, 0 bugs, 1 gap,
  with a JSON Pointer and the concrete schema that would fix it. It is the description-only `allOf`
  member on `SPMetrics.fb`, which takes nothing away from the caller.
- **[`TYPE_GAPS.md`](TYPE_GAPS.md)** — **0 blocking positions.** No operation returns `Any` and no
  model attribute is typed `Any`. Under spec `v1.0.0` this was 1: `GET /mangools/locations/{location}`
  declared a `404` with no `content` and widened the parsed union to `Union[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:

```python
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:

```console
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](LICENSE).
