Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

lihi

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

Read this in another language: 繁體中文 · 简体中文 · 日本語 · 한국어 · Español · Português · Français · Italiano · Русский

  • 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
pip install lihi

Requires Python 3.10 or newer.

Getting started

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.

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

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

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

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

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

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:

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

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:

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:

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.

import logging

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

Limits

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.

About

Official Python client for the Lihi URL shortener and SMS API.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages