gangtise-openapi 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 (42) hide show
  1. gangtise_openapi-0.1.0/.gitignore +16 -0
  2. gangtise_openapi-0.1.0/CHANGELOG.md +16 -0
  3. gangtise_openapi-0.1.0/LICENSE +21 -0
  4. gangtise_openapi-0.1.0/PKG-INFO +90 -0
  5. gangtise_openapi-0.1.0/README.md +64 -0
  6. gangtise_openapi-0.1.0/pyproject.toml +84 -0
  7. gangtise_openapi-0.1.0/src/gangtise_openapi/__about__.py +1 -0
  8. gangtise_openapi-0.1.0/src/gangtise_openapi/__init__.py +28 -0
  9. gangtise_openapi-0.1.0/src/gangtise_openapi/_async_content.py +98 -0
  10. gangtise_openapi-0.1.0/src/gangtise_openapi/_auth.py +89 -0
  11. gangtise_openapi-0.1.0/src/gangtise_openapi/_client.py +456 -0
  12. gangtise_openapi-0.1.0/src/gangtise_openapi/_config.py +56 -0
  13. gangtise_openapi-0.1.0/src/gangtise_openapi/_download.py +256 -0
  14. gangtise_openapi-0.1.0/src/gangtise_openapi/_endpoints.py +527 -0
  15. gangtise_openapi-0.1.0/src/gangtise_openapi/_errors.py +58 -0
  16. gangtise_openapi-0.1.0/src/gangtise_openapi/_facade.py +168 -0
  17. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/__init__.py +30 -0
  18. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/announcement_categories.py +97 -0
  19. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/broker_orgs.py +185 -0
  20. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/industries.py +36 -0
  21. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/industry_codes.py +36 -0
  22. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/meeting_orgs.py +135 -0
  23. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/regions.py +24 -0
  24. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/research_areas.py +71 -0
  25. gangtise_openapi-0.1.0/src/gangtise_openapi/_lookup/theme_ids.py +408 -0
  26. gangtise_openapi-0.1.0/src/gangtise_openapi/_normalize.py +25 -0
  27. gangtise_openapi-0.1.0/src/gangtise_openapi/_pagination.py +168 -0
  28. gangtise_openapi-0.1.0/src/gangtise_openapi/_quote_sharding.py +90 -0
  29. gangtise_openapi-0.1.0/src/gangtise_openapi/_title_cache.py +106 -0
  30. gangtise_openapi-0.1.0/src/gangtise_openapi/_transport.py +161 -0
  31. gangtise_openapi-0.1.0/src/gangtise_openapi/_transport_async.py +101 -0
  32. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/__init__.py +30 -0
  33. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/ai.py +570 -0
  34. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/alternative.py +141 -0
  35. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/auth.py +62 -0
  36. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/fundamental.py +729 -0
  37. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/insight.py +1386 -0
  38. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/lookup.py +98 -0
  39. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/quote.py +476 -0
  40. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/reference.py +99 -0
  41. gangtise_openapi-0.1.0/src/gangtise_openapi/domains/vault.py +520 -0
  42. gangtise_openapi-0.1.0/src/gangtise_openapi/py.typed +0 -0
@@ -0,0 +1,16 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ .DS_Store
15
+
16
+ .claude/
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] — 2026-05-28
8
+
9
+ ### Added
10
+ - Initial release.
11
+ - 73 endpoints from gangtise-openapi-cli v0.14.2.
12
+ - Sync (`gangtise`) and async (`gangtise.async_`) APIs.
13
+ - DataFrame-by-default returns with `raw=True` escape hatch.
14
+ - Auto-pagination concurrency, retry + token self-heal, K-line full-market date sharding, transparent async-content polling.
15
+ - Token cache shared with the npm CLI at `~/.config/gangtise/token.json`.
16
+ - Initial scaffold (pyproject, tooling, CI).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gangtiser
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,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: gangtise-openapi
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Gangtise OpenAPI
5
+ Project-URL: Homepage, https://github.com/gangtiser/gangtise-python
6
+ Project-URL: Source, https://github.com/gangtiser/gangtise-python
7
+ Project-URL: Issues, https://github.com/gangtiser/gangtise-python/issues
8
+ Author: gangtiser
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: finance,gangtise,openapi,quote,research,sdk
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: MIT License
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: Topic :: Office/Business :: Financial
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: anyio>=4.0
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: pandas>=2.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # gangtise-openapi
28
+
29
+ Python SDK for [Gangtise OpenAPI](https://open.gangtise.com). Feature-parity with the npm CLI [`gangtise-openapi-cli`](https://github.com/gangtiser/gangtise-openapi-cli) v0.14.2 across 73 endpoints.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install gangtise-openapi
35
+ ```
36
+
37
+ Requires Python 3.10+.
38
+
39
+ ## Configure
40
+
41
+ ```bash
42
+ export GANGTISE_ACCESS_KEY=ak_xxx
43
+ export GANGTISE_SECRET_KEY=sk_xxx
44
+ ```
45
+
46
+ (Or pass `access_key=` and `secret_key=` explicitly to `GangtiseClient`.) The token cache file at `~/.config/gangtise/token.json` is shared with the npm CLI.
47
+
48
+ ## Quickstart
49
+
50
+ ```python
51
+ from gangtise_openapi import gangtise
52
+
53
+ # Tabular endpoints return a pandas DataFrame
54
+ df = gangtise.quote.day_kline(
55
+ security="000001.SH",
56
+ start_date="2026-01-01",
57
+ end_date="2026-01-31",
58
+ )
59
+
60
+ # Use raw=True to get the underlying dict/list
61
+ result = gangtise.insight.opinion_list(industry=1, size=20, raw=True)
62
+
63
+ # Async
64
+ import asyncio
65
+
66
+ async def main():
67
+ df = await gangtise.async_.quote.day_kline(security="000001.SH")
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ ## Endpoints
73
+
74
+ The SDK exposes 73 endpoints across 9 domains:
75
+
76
+ - `gangtise.auth.*` — login, status
77
+ - `gangtise.lookup.*` — local lookup tables (research areas, brokers, industries, ...)
78
+ - `gangtise.reference.*` — securities search (GTS codes)
79
+ - `gangtise.insight.*` — opinions, research reports, announcements, schedules
80
+ - `gangtise.quote.*` — K-line, real-time quotes
81
+ - `gangtise.fundamental.*` — financial statements, valuation, holders, forecasts
82
+ - `gangtise.ai.*` — AI-generated insights (one-pager, peer comparison, earnings reviews, ...)
83
+ - `gangtise.vault.*` — personal drive, meeting records, stock pools, WeChat
84
+ - `gangtise.alternative.*` — economic indicators (EDB)
85
+
86
+ See the [npm CLI README](https://github.com/gangtiser/gangtise-openapi-cli#readme) for endpoint-by-endpoint documentation; the Python wrappers accept the same parameters as the CLI flags (snake_case instead of `--kebab-case`).
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,64 @@
1
+ # gangtise-openapi
2
+
3
+ Python SDK for [Gangtise OpenAPI](https://open.gangtise.com). Feature-parity with the npm CLI [`gangtise-openapi-cli`](https://github.com/gangtiser/gangtise-openapi-cli) v0.14.2 across 73 endpoints.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install gangtise-openapi
9
+ ```
10
+
11
+ Requires Python 3.10+.
12
+
13
+ ## Configure
14
+
15
+ ```bash
16
+ export GANGTISE_ACCESS_KEY=ak_xxx
17
+ export GANGTISE_SECRET_KEY=sk_xxx
18
+ ```
19
+
20
+ (Or pass `access_key=` and `secret_key=` explicitly to `GangtiseClient`.) The token cache file at `~/.config/gangtise/token.json` is shared with the npm CLI.
21
+
22
+ ## Quickstart
23
+
24
+ ```python
25
+ from gangtise_openapi import gangtise
26
+
27
+ # Tabular endpoints return a pandas DataFrame
28
+ df = gangtise.quote.day_kline(
29
+ security="000001.SH",
30
+ start_date="2026-01-01",
31
+ end_date="2026-01-31",
32
+ )
33
+
34
+ # Use raw=True to get the underlying dict/list
35
+ result = gangtise.insight.opinion_list(industry=1, size=20, raw=True)
36
+
37
+ # Async
38
+ import asyncio
39
+
40
+ async def main():
41
+ df = await gangtise.async_.quote.day_kline(security="000001.SH")
42
+
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ ## Endpoints
47
+
48
+ The SDK exposes 73 endpoints across 9 domains:
49
+
50
+ - `gangtise.auth.*` — login, status
51
+ - `gangtise.lookup.*` — local lookup tables (research areas, brokers, industries, ...)
52
+ - `gangtise.reference.*` — securities search (GTS codes)
53
+ - `gangtise.insight.*` — opinions, research reports, announcements, schedules
54
+ - `gangtise.quote.*` — K-line, real-time quotes
55
+ - `gangtise.fundamental.*` — financial statements, valuation, holders, forecasts
56
+ - `gangtise.ai.*` — AI-generated insights (one-pager, peer comparison, earnings reviews, ...)
57
+ - `gangtise.vault.*` — personal drive, meeting records, stock pools, WeChat
58
+ - `gangtise.alternative.*` — economic indicators (EDB)
59
+
60
+ See the [npm CLI README](https://github.com/gangtiser/gangtise-openapi-cli#readme) for endpoint-by-endpoint documentation; the Python wrappers accept the same parameters as the CLI flags (snake_case instead of `--kebab-case`).
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gangtise-openapi"
7
+ dynamic = ["version"]
8
+ description = "Python SDK for Gangtise OpenAPI"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "gangtiser" }]
13
+ keywords = ["gangtise", "finance", "openapi", "sdk", "quote", "research"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Financial and Insurance Industry",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Office/Business :: Financial",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27",
27
+ "pandas>=2.0",
28
+ "anyio>=4.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/gangtiser/gangtise-python"
33
+ Source = "https://github.com/gangtiser/gangtise-python"
34
+ Issues = "https://github.com/gangtiser/gangtise-python/issues"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=8.0",
39
+ "anyio[trio]>=4.0",
40
+ "respx>=0.21",
41
+ "ruff>=0.6",
42
+ "mypy>=1.10",
43
+ "pandas-stubs",
44
+ ]
45
+
46
+ [tool.hatch.version]
47
+ path = "src/gangtise_openapi/__about__.py"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/gangtise_openapi"]
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["src/", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py310"
58
+ src = ["src", "tests"]
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
62
+ ignore = ["E501"]
63
+
64
+ [tool.ruff.lint.per-file-ignores]
65
+ "tests/**" = ["B008"]
66
+
67
+ [tool.mypy]
68
+ files = ["src"]
69
+ python_version = "3.10"
70
+ strict = true
71
+ warn_unused_ignores = true
72
+ warn_return_any = true
73
+ ignore_missing_imports = false
74
+
75
+ [[tool.mypy.overrides]]
76
+ module = ["pandas.*", "respx.*"]
77
+ ignore_missing_imports = true
78
+
79
+ [tool.pytest.ini_options]
80
+ testpaths = ["tests"]
81
+ addopts = "-ra --strict-markers"
82
+ markers = [
83
+ "live: hits real Gangtise API (skipped by default; run with `pytest -m live`)",
84
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,28 @@
1
+ import logging as _logging
2
+ import os as _os
3
+
4
+ from gangtise_openapi.__about__ import __version__
5
+ from gangtise_openapi._client import AsyncGangtiseClient, GangtiseClient
6
+ from gangtise_openapi._errors import (
7
+ ApiError,
8
+ ConfigError,
9
+ DownloadError,
10
+ GangtiseError,
11
+ ValidationError,
12
+ )
13
+ from gangtise_openapi._facade import gangtise
14
+
15
+ __all__ = [
16
+ "ApiError",
17
+ "AsyncGangtiseClient",
18
+ "ConfigError",
19
+ "DownloadError",
20
+ "GangtiseClient",
21
+ "GangtiseError",
22
+ "ValidationError",
23
+ "__version__",
24
+ "gangtise",
25
+ ]
26
+
27
+ if _os.environ.get("GANGTISE_VERBOSE") in {"1", "true", "True", "yes", "YES"}:
28
+ _logging.getLogger("gangtise_openapi").setLevel(_logging.DEBUG)
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import anyio
8
+
9
+ from gangtise_openapi._errors import ApiError
10
+
11
+ POLL_MAX_ATTEMPTS = 14
12
+ _INITIAL_DELAY_S = 5.0
13
+ _MAX_DELAY_S = 30.0
14
+ _GROWTH = 1.6
15
+
16
+ CODE_PENDING = "410110"
17
+ CODE_TERMINAL = "410111"
18
+
19
+
20
+ def next_delay_seconds(attempt: int) -> float:
21
+ grown = _INITIAL_DELAY_S * (_GROWTH ** (attempt - 1))
22
+ return min(_MAX_DELAY_S, float(round(grown)))
23
+
24
+
25
+ Fetcher = Callable[[], Any]
26
+ Sleeper = Callable[[float], None]
27
+
28
+
29
+ def _classify(error: ApiError) -> str:
30
+ if error.code == CODE_PENDING:
31
+ return "pending"
32
+ if error.code == CODE_TERMINAL:
33
+ return "terminal"
34
+ return "other"
35
+
36
+
37
+ def poll_content(
38
+ fetch: Fetcher,
39
+ *,
40
+ sleep: Sleeper = time.sleep,
41
+ max_attempts: int = POLL_MAX_ATTEMPTS,
42
+ ) -> Any:
43
+ last_pending: ApiError | None = None
44
+ for attempt in range(1, max_attempts + 1):
45
+ try:
46
+ result = fetch()
47
+ except ApiError as error:
48
+ kind = _classify(error)
49
+ if kind == "terminal":
50
+ raise ApiError(
51
+ "Content generation failed (terminal). Do not retry.",
52
+ code=CODE_TERMINAL,
53
+ ) from error
54
+ if kind == "other":
55
+ raise
56
+ last_pending = error
57
+ else:
58
+ if isinstance(result, dict) and result.get("content") is not None:
59
+ return result
60
+ if attempt < max_attempts:
61
+ sleep(next_delay_seconds(attempt))
62
+ raise ApiError(
63
+ f"Content not available after {max_attempts} attempts",
64
+ code=CODE_PENDING,
65
+ ) from last_pending
66
+
67
+
68
+ AsyncFetcher = Callable[[], Any]
69
+
70
+
71
+ async def poll_content_async(
72
+ fetch: AsyncFetcher,
73
+ *,
74
+ max_attempts: int = POLL_MAX_ATTEMPTS,
75
+ ) -> Any:
76
+ last_pending: ApiError | None = None
77
+ for attempt in range(1, max_attempts + 1):
78
+ try:
79
+ result = await fetch()
80
+ except ApiError as error:
81
+ kind = _classify(error)
82
+ if kind == "terminal":
83
+ raise ApiError(
84
+ "Content generation failed (terminal). Do not retry.",
85
+ code=CODE_TERMINAL,
86
+ ) from error
87
+ if kind == "other":
88
+ raise
89
+ last_pending = error
90
+ else:
91
+ if isinstance(result, dict) and result.get("content") is not None:
92
+ return result
93
+ if attempt < max_attempts:
94
+ await anyio.sleep(next_delay_seconds(attempt))
95
+ raise ApiError(
96
+ f"Content not available after {max_attempts} attempts",
97
+ code=CODE_PENDING,
98
+ ) from last_pending
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from gangtise_openapi._errors import ConfigError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TokenCache:
14
+ access_token: str
15
+ expires_in: int
16
+ time: int
17
+ expires_at: int
18
+ uid: int | None = None
19
+ user_name: str | None = None
20
+ tenant_id: int | None = None
21
+
22
+
23
+ _BUFFER_SECONDS = 300
24
+
25
+
26
+ def normalize_token(token: str) -> str:
27
+ return token if token.startswith("Bearer ") else f"Bearer {token}"
28
+
29
+
30
+ def is_cache_valid(cache: TokenCache | None, buffer_seconds: int = _BUFFER_SECONDS) -> bool:
31
+ if cache is None or not cache.access_token or not cache.expires_at:
32
+ return False
33
+ now = int(time.time())
34
+ return (cache.expires_at - buffer_seconds) > now
35
+
36
+
37
+ def read_token_cache(path: Path) -> TokenCache | None:
38
+ try:
39
+ raw = path.read_text(encoding="utf8")
40
+ except OSError:
41
+ return None
42
+ try:
43
+ data: object = json.loads(raw)
44
+ except json.JSONDecodeError:
45
+ return None
46
+ if not isinstance(data, dict):
47
+ return None
48
+ access_token = data.get("accessToken")
49
+ expires_at = data.get("expiresAt")
50
+ if not isinstance(access_token, str) or not isinstance(expires_at, int):
51
+ return None
52
+ expires_in_raw = data.get("expiresIn", 0)
53
+ time_raw = data.get("time", 0)
54
+ uid = data.get("uid")
55
+ user_name = data.get("userName")
56
+ tenant_id = data.get("tenantId")
57
+ return TokenCache(
58
+ access_token=access_token,
59
+ expires_in=int(expires_in_raw) if isinstance(expires_in_raw, int) else 0,
60
+ time=int(time_raw) if isinstance(time_raw, int) else 0,
61
+ expires_at=expires_at,
62
+ uid=uid if isinstance(uid, int) else None,
63
+ user_name=user_name if isinstance(user_name, str) else None,
64
+ tenant_id=tenant_id if isinstance(tenant_id, int) else None,
65
+ )
66
+
67
+
68
+ def write_token_cache(path: Path, cache: TokenCache) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ payload = {
71
+ "accessToken": cache.access_token,
72
+ "expiresIn": cache.expires_in,
73
+ "time": cache.time,
74
+ "expiresAt": cache.expires_at,
75
+ "uid": cache.uid,
76
+ "userName": cache.user_name,
77
+ "tenantId": cache.tenant_id,
78
+ }
79
+ # Atomic write: temp file + rename to avoid partial reads.
80
+ tmp = path.with_suffix(path.suffix + ".tmp")
81
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf8")
82
+ os.chmod(tmp, 0o600)
83
+ tmp.replace(path)
84
+
85
+
86
+ def require_credentials(access_key: str | None, secret_key: str | None) -> tuple[str, str]:
87
+ if not access_key or not secret_key:
88
+ raise ConfigError("Missing GANGTISE_ACCESS_KEY or GANGTISE_SECRET_KEY")
89
+ return access_key, secret_key