Metadata-Version: 2.4
Name: lihi-py
Version: 1.0.0a0
Summary: Python SDK for the Lihi short-URL and SMS API
Project-URL: Homepage, https://github.com/lihi-io/lihi-py
Project-URL: Source, https://github.com/lihi-io/lihi-py
Author: Lihi
License: MIT License
        
        Copyright (c) 2026 lihi.io
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: api,lihi,sdk,short-url,sms
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
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: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: tzdata; platform_system == 'Windows'
Description-Content-Type: text/markdown

# lihi

Python SDK for the Lihi short-URL and SMS API.

**Read this in another language:**
[繁體中文](docs/README.zh-TW.md) ·
[简体中文](docs/README.zh-CN.md) ·
[日本語](docs/README.ja.md) ·
[한국어](docs/README.ko.md) ·
[Español](docs/README.es.md) ·
[Português](docs/README.pt.md) ·
[Français](docs/README.fr.md) ·
[Italiano](docs/README.it.md) ·
[Русский](docs/README.ru.md)

- One exception hierarchy across every endpoint — the API's error formats vary,
  and the SDK normalises them
- Fully typed, `py.typed` included
- Client-side checks that stop billable mistakes before they are sent
- One runtime dependency: `httpx`

```bash
pip install lihi
```

Requires Python 3.10 or newer.

## Getting started

```python
from lihi import Lihi

with Lihi(short_url_api_key="...") as client:
    link = client.short_url.create("https://example.com/a-long-page")
    print(link)
```

Each API has its own credential; pass only the ones you use. Calling a service
you have no key for raises `ConfigurationError` immediately, without a request.

```python
client = Lihi(
    basic_api_key="...",
    short_url_api_key="...",
    sms_api_key="...",
)
```

Close the client when you are done, or use it as a context manager. If you pass
your own `http_client`, closing it stays your responsibility.

## Async

```python
import asyncio
from lihi import AsyncLihi


async def main() -> None:
    async with AsyncLihi(short_url_api_key="...") as client:
        link = await client.short_url.create("https://example.com")
        print(link)


asyncio.run(main())
```

Build one client per event loop: the underlying connection pool binds to the
loop that created it, so a module-level singleton breaks in a script that calls
`asyncio.run()` more than once.

## Short URLs

```python
link = client.short_url.create(
    "https://example.com",
    slug="spring-sale",
    title="Spring sale",
    tags=["campaign", "2026"],
    expired_at=datetime(2026, 12, 31, 23, 59, 59),
)

record = client.short_url.get(link)  # None if there is no such link
record = client.short_url.get_or_raise(link)  # raises NotFoundError instead

print(record.click, record.total_click, record.created_at)

client.short_url.update(link, "https://example.com/new-target")
client.short_url.delete(link)
```

`update` replaces rather than patches, which is why both URLs are required
positional arguments — omitting the target would blank it.

`click` is reported by the API and counted differently by the single and batch
endpoints, so do not compare it across the two. `total_click` is summed by the
SDK and means the same thing everywhere.

### Batches

```python
found = client.short_url.batch_get(["https://lihi.io/a", "https://lihi.io/b"])
# {"https://lihi.io/a": ShortURL(...), "https://lihi.io/b": None}

result = client.short_url.batch_create(
    "spring campaign",
    "lihi.io",
    [BatchURL(url="https://example.com/1"), BatchURL(url="https://example.com/2")],
)
print(result.count, result.short_urls)

client.short_url.batch_delete(["https://lihi.io/a"])
```

`batch_get` keys the result by the exact strings you passed in, so duplicates
collapse into one entry. Every input gets a key; a link that does not exist maps
to `None`.

## SMS

```python
client.sms.send_otp("0912345678")  # normalised to E.164
ok = client.sms.verify_otp("0912345678", "123456")

template = client.sms.create_bulk(
    ["0912345678", "0987654321"],
    "Your order has shipped.",
)

status = client.sms.get_template(template.id)
if status and status.is_finished:
    print(status.success_count, status.failed_count)

client.sms.cancel_template(template.id)
```

OTP numbers are normalised using the client's `default_country_code`. Spaces,
dashes, dots and brackets are stripped first, so all of these reach the same
number:

| Given | Sent |
|---|---|
| `0912345678` | `+886912345678` |
| `09 1234-5678` | `+886912345678` |
| `912345678` | `+886912345678` |
| `00886912345678` | `+886912345678` |
| `+886912345678` | `+886912345678` |

**Write the country code with a `+`, or leave it off entirely.** A number that
opens with the country code and no `+` — `886912345678` — is read as a local
number, and the country code is prefixed on top of it. Use `+886912345678` or
`0912345678` instead.

Bulk recipients are sent exactly as given, with no normalisation.

`verify_otp` returns `False` only when the code was wrong. A server fault, a
rate limit or a bad credential still raises.

### Estimating cost

```python
from lihi import estimate_points

points = estimate_points(phones, content)
```

One point per 70 characters, per recipient, counted in code points — an emoji
is one character. This is an estimate; your account's rate is what bills.

If your account has a registered brand, set it and the SDK will check that bulk
content opens with it:

```python
client = Lihi(sms_api_key="...", brand="Acme")
client.sms.create_bulk(phones, "[Acme] Your order has shipped.")
```

`Acme …`, `[Acme] …` and `【Acme】…` all pass. The SDK never adds the prefix for
you, because adding characters would change the point cost after you had
already estimated it.

## Errors

Everything raised inherits from `LihiError`:

```
LihiError
├── ConfigurationError        the client is set up wrong
├── AuthenticationError       the credential was rejected
├── PermissionDeniedError     valid credential, not allowed to do this
├── QuotaExceededError        an account limit was reached
├── UpstreamRejectedError     refused, without a stated reason
├── ValidationError           malformed request, or a client-side check
│   └── InsufficientPointsError
├── NotFoundError
├── RateLimitError            carries retry_after and retryable
├── ServerError
└── TransportError            the request never completed
    └── RequestTimeoutError
```

```python
from lihi import LihiError, RateLimitError, ValidationError

try:
    client.sms.create_bulk(phones, content)
except InsufficientPointsError:
    ...  # also caught by `except ValidationError`
except RateLimitError as exc:
    time.sleep(exc.retry_after or 60)
except LihiError as exc:
    log.error("%s (%s)", exc, exc.code)
```

Every error carries `message`, `status_code`, `code`, `errors` and
`request_summary`. `errors` is always a dict, so `exc.errors.get("phone")` is
safe without a guard. `response` holds the raw response and is readable —
`exc.response.text` works.

A timeout is worth handling separately: the message says the write may already
have been applied, because that is the only thing you can act on.

```python
except RequestTimeoutError:
    # Query before retrying — the request may have gone through.
```

### Retries

GET requests are retried twice by default, on 5xx, 429 and transport failures,
backing off 0.5s, 1s, 2s. A `Retry-After` header is honoured up to 30 seconds;
past that the SDK stops rather than stalling your process.

Writes are never retried automatically, because repeating one can double-charge
you or send a message twice. If you want that anyway:

```python
Lihi(..., retry_unsafe_methods=True)  # repeats writes; use with care
```

`RateLimitError.retryable` tells you whether retrying by hand is worth it. It is
not what drives the SDK's own retries — those go by the status code.

## Testing your own code

`lihi.testing` ships with the package, so you can exercise your error handling
without a network:

```python
from lihi.testing import MockAPI


def test_handles_a_taken_slug():
    mock = MockAPI()
    mock.queue_json(400, {"result": "failed", "msg": {"error": "the slug has already been taken"}})

    with mock.client(short_url_api_key="k") as client:
        with pytest.raises(ValidationError) as caught:
            client.short_url.create("https://example.com", slug="taken")

    assert caught.value.code == "slug_taken"
    mock.assert_request_sent("POST", "/api/v1/url")
```

`mock.client()` and `mock.async_client()` both work from the same `MockAPI`.
Queue responses with `queue_json`, `queue_response`, `queue_stream` or
`queue_error`; inspect what was sent with `requests`, `last_request` and the
`assert_*` helpers. An unstubbed request fails loudly rather than looking like
an empty 200.

## Configuration

| Option | Default | Notes |
|---|---|---|
| `base_url` | `https://app.lihi.io` | |
| `timeout` | `10.0` | Per attempt, not per call |
| `retry` | `True` | GET only, unless `retry_unsafe_methods` |
| `max_retries` | `2` | |
| `retry_unsafe_methods` | `False` | Repeats writes; can double-charge |
| `default_country_code` | `"886"` | Used for OTP normalisation |
| `timezone` | `"Asia/Taipei"` | Wall-clock timestamps are read in this zone |
| `brand` | `None` | Enables the bulk-content prefix check |
| `http_client` | built for you | Pass your own; you then close it |
| `logger` | `None` | No logging unless you pass one |
| `user_agent` | `lihi-python/{version}` | |

Timestamps sent and received have no zone marker and are wall-clock time in
`timezone`. A naive `datetime` is taken to be in that zone already; an aware one
is converted into it.

## Logging

Pass a logger to see requests, retries and failures. Credentials, query strings
and bodies are never logged.

```python
import logging

Lihi(..., logger=logging.getLogger("lihi"))
```

## Limits

```python
from lihi import MAX_BATCH_GET, MAX_BATCH_CREATE, MAX_BULK_RECIPIENTS
```

Exported so you can chunk on your side. Requests that exceed them are rejected
locally, before being sent.

## Licence

MIT.
