oh-my-common 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.
- oh_my_common-0.1.0/PKG-INFO +63 -0
- oh_my_common-0.1.0/README.md +45 -0
- oh_my_common-0.1.0/oh_my_common/__init__.py +28 -0
- oh_my_common-0.1.0/oh_my_common/claude.py +102 -0
- oh_my_common-0.1.0/oh_my_common/scheduler.py +11 -0
- oh_my_common-0.1.0/oh_my_common/settings.py +168 -0
- oh_my_common-0.1.0/oh_my_common/tz.py +21 -0
- oh_my_common-0.1.0/oh_my_common/urls.py +6 -0
- oh_my_common-0.1.0/oh_my_common.egg-info/PKG-INFO +63 -0
- oh_my_common-0.1.0/oh_my_common.egg-info/SOURCES.txt +13 -0
- oh_my_common-0.1.0/oh_my_common.egg-info/dependency_links.txt +1 -0
- oh_my_common-0.1.0/oh_my_common.egg-info/requires.txt +6 -0
- oh_my_common-0.1.0/oh_my_common.egg-info/top_level.txt +1 -0
- oh_my_common-0.1.0/pyproject.toml +29 -0
- oh_my_common-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oh-my-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared runtime for the oh-my-* Flask services: settings DB, Claude CLI gateway, timezone, base-url, scheduler.
|
|
5
|
+
Author-email: Dennis L <dennisl@udel.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ByteDennis/oh-my-common
|
|
8
|
+
Keywords: oh-my,flask,claude,settings
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: flask
|
|
15
|
+
Requires-Dist: flask>=3.0; extra == "flask"
|
|
16
|
+
Provides-Extra: scheduler
|
|
17
|
+
Requires-Dist: apscheduler>=3.10; extra == "scheduler"
|
|
18
|
+
|
|
19
|
+
# oh-my-common
|
|
20
|
+
|
|
21
|
+
Shared runtime for the `oh-my-*` Flask services. Extracted so each split-out
|
|
22
|
+
service (oh-my-rss / oh-my-msg / oh-my-write) shares one implementation instead
|
|
23
|
+
of drifting copies. Per the split design, each service keeps its **own**
|
|
24
|
+
settings DB — this package is the **code** layer, not a shared store.
|
|
25
|
+
|
|
26
|
+
## Modules
|
|
27
|
+
|
|
28
|
+
- **`settings`** — SQLite settings client + secret resolution.
|
|
29
|
+
`get_setting/put_setting/get_all`, the `provider_tokens` cache
|
|
30
|
+
(`get/set/clear_cached_token`), and `resolve_secret(key, env_key)` with
|
|
31
|
+
precedence **service-namespace > `global` > env**. Convenience:
|
|
32
|
+
`resolve_claude_token()`, `resolve_openai_key()`.
|
|
33
|
+
Config via env: `SETTINGS_DB` (path), `OMI_SERVICE_NS` (this service's
|
|
34
|
+
namespace, e.g. `clipboard`).
|
|
35
|
+
- **`claude`** — Claude CLI gateway: `resolve_claude_bin`, `claude_text`,
|
|
36
|
+
`run_claude_capture`, `gate_ai`, `claude_status`, `extract_json`,
|
|
37
|
+
`estimate_tokens`, `estimate_cost`, `MODEL_PRICING`. Flask-free.
|
|
38
|
+
- **`tz`** — `local_tz()/now_local()/today_local()` from `OMI_TZ_OFFSET` (hours, default +8).
|
|
39
|
+
- **`urls`** — `request_base_url()` (honors `X-Forwarded-Proto/Host`). Imports flask lazily.
|
|
40
|
+
- **`scheduler`** — `make_scheduler(ENABLED_ENV)` → a guarded `BackgroundScheduler`
|
|
41
|
+
(returns `None` when `ENABLED_ENV=0`, so extra gunicorn workers don't double-run).
|
|
42
|
+
Imports apscheduler lazily.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
os.environ.setdefault('OMI_SERVICE_NS', 'clipboard')
|
|
49
|
+
from oh_my_common import get_setting, claude_text, resolve_openai_key, now_local
|
|
50
|
+
|
|
51
|
+
key, src = resolve_openai_key()
|
|
52
|
+
text = claude_text('Summarize: ...', model='sonnet')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pip install oh-my-common # core (stdlib only)
|
|
59
|
+
pip install 'oh-my-common[flask,scheduler]' # + request_base_url + make_scheduler
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Distribution into Docker services is TBD — see the migration plan. Options:
|
|
63
|
+
publish to a registry, vendor into each monorepo, or a git dependency.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# oh-my-common
|
|
2
|
+
|
|
3
|
+
Shared runtime for the `oh-my-*` Flask services. Extracted so each split-out
|
|
4
|
+
service (oh-my-rss / oh-my-msg / oh-my-write) shares one implementation instead
|
|
5
|
+
of drifting copies. Per the split design, each service keeps its **own**
|
|
6
|
+
settings DB — this package is the **code** layer, not a shared store.
|
|
7
|
+
|
|
8
|
+
## Modules
|
|
9
|
+
|
|
10
|
+
- **`settings`** — SQLite settings client + secret resolution.
|
|
11
|
+
`get_setting/put_setting/get_all`, the `provider_tokens` cache
|
|
12
|
+
(`get/set/clear_cached_token`), and `resolve_secret(key, env_key)` with
|
|
13
|
+
precedence **service-namespace > `global` > env**. Convenience:
|
|
14
|
+
`resolve_claude_token()`, `resolve_openai_key()`.
|
|
15
|
+
Config via env: `SETTINGS_DB` (path), `OMI_SERVICE_NS` (this service's
|
|
16
|
+
namespace, e.g. `clipboard`).
|
|
17
|
+
- **`claude`** — Claude CLI gateway: `resolve_claude_bin`, `claude_text`,
|
|
18
|
+
`run_claude_capture`, `gate_ai`, `claude_status`, `extract_json`,
|
|
19
|
+
`estimate_tokens`, `estimate_cost`, `MODEL_PRICING`. Flask-free.
|
|
20
|
+
- **`tz`** — `local_tz()/now_local()/today_local()` from `OMI_TZ_OFFSET` (hours, default +8).
|
|
21
|
+
- **`urls`** — `request_base_url()` (honors `X-Forwarded-Proto/Host`). Imports flask lazily.
|
|
22
|
+
- **`scheduler`** — `make_scheduler(ENABLED_ENV)` → a guarded `BackgroundScheduler`
|
|
23
|
+
(returns `None` when `ENABLED_ENV=0`, so extra gunicorn workers don't double-run).
|
|
24
|
+
Imports apscheduler lazily.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
os.environ.setdefault('OMI_SERVICE_NS', 'clipboard')
|
|
31
|
+
from oh_my_common import get_setting, claude_text, resolve_openai_key, now_local
|
|
32
|
+
|
|
33
|
+
key, src = resolve_openai_key()
|
|
34
|
+
text = claude_text('Summarize: ...', model='sonnet')
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install oh-my-common # core (stdlib only)
|
|
41
|
+
pip install 'oh-my-common[flask,scheduler]' # + request_base_url + make_scheduler
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Distribution into Docker services is TBD — see the migration plan. Options:
|
|
45
|
+
publish to a registry, vendor into each monorepo, or a git dependency.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .settings import (
|
|
2
|
+
SETTINGS_DB, SERVICE_NS,
|
|
3
|
+
init_settings_db, seed_global_settings_from_env,
|
|
4
|
+
get_setting, put_setting, get_all,
|
|
5
|
+
init_token_cache, get_cached_token, set_cached_token, clear_cached_token,
|
|
6
|
+
resolve_secret, resolve_claude_token, resolve_openai_key,
|
|
7
|
+
)
|
|
8
|
+
from .claude import (
|
|
9
|
+
MODEL_PRICING, resolve_claude_bin, claude_env, claude_status, gate_ai,
|
|
10
|
+
run_claude_capture, claude_text, extract_json, estimate_tokens, estimate_cost,
|
|
11
|
+
)
|
|
12
|
+
from .tz import local_tz, now_local, today_local
|
|
13
|
+
from .urls import request_base_url
|
|
14
|
+
from .scheduler import make_scheduler
|
|
15
|
+
|
|
16
|
+
__version__ = '0.1.0'
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
'SETTINGS_DB', 'SERVICE_NS',
|
|
20
|
+
'init_settings_db', 'seed_global_settings_from_env',
|
|
21
|
+
'get_setting', 'put_setting', 'get_all',
|
|
22
|
+
'init_token_cache', 'get_cached_token', 'set_cached_token', 'clear_cached_token',
|
|
23
|
+
'resolve_secret', 'resolve_claude_token', 'resolve_openai_key',
|
|
24
|
+
'MODEL_PRICING', 'resolve_claude_bin', 'claude_env', 'claude_status', 'gate_ai',
|
|
25
|
+
'run_claude_capture', 'claude_text', 'extract_json', 'estimate_tokens', 'estimate_cost',
|
|
26
|
+
'local_tz', 'now_local', 'today_local',
|
|
27
|
+
'request_base_url', 'make_scheduler',
|
|
28
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from .settings import resolve_claude_token
|
|
8
|
+
|
|
9
|
+
MODEL_PRICING = {
|
|
10
|
+
'haiku': (1.0, 5.0),
|
|
11
|
+
'sonnet': (3.0, 15.0),
|
|
12
|
+
'opus': (15.0, 75.0),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# >>> locate the claude binary: $CLAUDE_BIN > PATH > newest /opt/claude/versions/* <<< #
|
|
17
|
+
def resolve_claude_bin() -> str:
|
|
18
|
+
if os.environ.get('CLAUDE_BIN'):
|
|
19
|
+
return os.environ['CLAUDE_BIN']
|
|
20
|
+
on_path = shutil.which('claude')
|
|
21
|
+
if on_path:
|
|
22
|
+
return on_path
|
|
23
|
+
cands = sorted(glob.glob('/opt/claude/versions/*'), key=os.path.getmtime)
|
|
24
|
+
return cands[-1] if cands else ''
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# >>> subprocess env carrying the OAuth token <<< #
|
|
28
|
+
def claude_env(token: str) -> dict:
|
|
29
|
+
env = os.environ.copy()
|
|
30
|
+
env['HOME'] = '/root'
|
|
31
|
+
env['CLAUDE_CODE_OAUTH_TOKEN'] = token
|
|
32
|
+
return env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# >>> readiness of the Claude gateway: {configured, has_token, has_binary} <<< #
|
|
36
|
+
def claude_status(service_ns: str = None) -> dict:
|
|
37
|
+
token, _src = resolve_claude_token(service_ns)
|
|
38
|
+
claude_bin = resolve_claude_bin()
|
|
39
|
+
return {'configured': bool(token and claude_bin),
|
|
40
|
+
'has_token': bool(token), 'has_binary': bool(claude_bin)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# >>> (token, claude_bin) if ready else (None, None) <<< #
|
|
44
|
+
def gate_ai(service_ns: str = None):
|
|
45
|
+
token, _src = resolve_claude_token(service_ns)
|
|
46
|
+
claude_bin = resolve_claude_bin()
|
|
47
|
+
if not token or not claude_bin:
|
|
48
|
+
return None, None
|
|
49
|
+
return token, claude_bin
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# >>> run `claude -p` once, return full stdout (blocking); raises on non-zero <<< #
|
|
53
|
+
def run_claude_capture(claude_bin, token, model, prompt, timeout=180):
|
|
54
|
+
proc = subprocess.run(
|
|
55
|
+
[claude_bin, '-p', '--model', model],
|
|
56
|
+
input=prompt, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
57
|
+
text=True, env=claude_env(token), timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
if proc.returncode != 0:
|
|
60
|
+
raise RuntimeError(proc.stderr.strip() or 'claude exited non-zero')
|
|
61
|
+
return proc.stdout
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# >>> one-shot Claude call; resolves the token if not supplied <<< #
|
|
65
|
+
def claude_text(prompt, model='sonnet', service_ns: str = None, timeout=180):
|
|
66
|
+
token, claude_bin = gate_ai(service_ns)
|
|
67
|
+
if not token or not claude_bin:
|
|
68
|
+
raise RuntimeError('Claude not configured')
|
|
69
|
+
return run_claude_capture(claude_bin, token, model, prompt, timeout).strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# >>> pull the first balanced JSON object out of a model reply (tolerates fences/prose) <<< #
|
|
73
|
+
def extract_json(s):
|
|
74
|
+
s = s.strip()
|
|
75
|
+
if s.startswith('```'):
|
|
76
|
+
s = s.split('\n', 1)[-1]
|
|
77
|
+
if s.endswith('```'):
|
|
78
|
+
s = s[:-3]
|
|
79
|
+
start = s.find('{')
|
|
80
|
+
if start == -1:
|
|
81
|
+
raise ValueError('no JSON object in reply')
|
|
82
|
+
depth = 0
|
|
83
|
+
for i in range(start, len(s)):
|
|
84
|
+
c = s[i]
|
|
85
|
+
if c == '{':
|
|
86
|
+
depth += 1
|
|
87
|
+
elif c == '}':
|
|
88
|
+
depth -= 1
|
|
89
|
+
if depth == 0:
|
|
90
|
+
return json.loads(s[start:i + 1])
|
|
91
|
+
raise ValueError('unbalanced JSON in reply')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# >>> rough token estimate (~4 chars/token) for cost accounting <<< #
|
|
95
|
+
def estimate_tokens(text) -> int:
|
|
96
|
+
return max(1, len(text or '') // 4)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# >>> USD cost of a call given char/token counts and model pricing <<< #
|
|
100
|
+
def estimate_cost(tok_in, tok_out, model='sonnet') -> float:
|
|
101
|
+
pin, pout = MODEL_PRICING.get(model, (3.0, 15.0))
|
|
102
|
+
return tok_in / 1e6 * pin + tok_out / 1e6 * pout
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# >>> BackgroundScheduler guarded by an env flag (set <FLAG>=0 on extra gunicorn workers to avoid duplicate runs); returns None when disabled <<< #
|
|
5
|
+
def make_scheduler(enabled_env: str, timezone_=None):
|
|
6
|
+
if os.environ.get(enabled_env, '1') == '0':
|
|
7
|
+
return None
|
|
8
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
9
|
+
from .tz import local_tz
|
|
10
|
+
sched = BackgroundScheduler(timezone=timezone_ or local_tz())
|
|
11
|
+
return sched
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
SETTINGS_DB = os.environ.get('SETTINGS_DB', '/data/oh-my-clipboard.db')
|
|
5
|
+
SERVICE_NS = os.environ.get('OMI_SERVICE_NS', 'app')
|
|
6
|
+
|
|
7
|
+
ENV_MAP = {
|
|
8
|
+
'claude_code_oauth_token': 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
9
|
+
'openai_api_key': 'OPENAI_API_KEY',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# >>> sqlite connection to the settings DB <<< #
|
|
14
|
+
def _conn():
|
|
15
|
+
conn = sqlite3.connect(SETTINGS_DB)
|
|
16
|
+
conn.row_factory = sqlite3.Row
|
|
17
|
+
return conn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# >>> create the settings table if missing <<< #
|
|
21
|
+
def init_settings_db():
|
|
22
|
+
os.makedirs(os.path.dirname(SETTINGS_DB), exist_ok=True)
|
|
23
|
+
conn = sqlite3.connect(SETTINGS_DB)
|
|
24
|
+
conn.execute('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)')
|
|
25
|
+
conn.commit()
|
|
26
|
+
conn.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# >>> seed global.* settings from env on first boot (only when unset) <<< #
|
|
30
|
+
def seed_global_settings_from_env():
|
|
31
|
+
init_settings_db()
|
|
32
|
+
conn = _conn()
|
|
33
|
+
for key, env_key in ENV_MAP.items():
|
|
34
|
+
val = os.environ.get(env_key, '')
|
|
35
|
+
if not val:
|
|
36
|
+
continue
|
|
37
|
+
existing = conn.execute(
|
|
38
|
+
'SELECT value FROM settings WHERE key = ?', (f'global.{key}',)
|
|
39
|
+
).fetchone()
|
|
40
|
+
if not existing or not existing['value']:
|
|
41
|
+
conn.execute(
|
|
42
|
+
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
|
|
43
|
+
(f'global.{key}', val),
|
|
44
|
+
)
|
|
45
|
+
conn.commit()
|
|
46
|
+
conn.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# >>> read one namespaced setting, falling back to default <<< #
|
|
50
|
+
def get_setting(namespace: str, key: str, default: str = '') -> str:
|
|
51
|
+
try:
|
|
52
|
+
conn = _conn()
|
|
53
|
+
row = conn.execute(
|
|
54
|
+
'SELECT value FROM settings WHERE key = ?', (f'{namespace}.{key}',)
|
|
55
|
+
).fetchone()
|
|
56
|
+
conn.close()
|
|
57
|
+
if row and row['value']:
|
|
58
|
+
return row['value']
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# >>> write one namespaced setting <<< #
|
|
65
|
+
def put_setting(namespace: str, key: str, value: str):
|
|
66
|
+
init_settings_db()
|
|
67
|
+
conn = _conn()
|
|
68
|
+
conn.execute(
|
|
69
|
+
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
|
|
70
|
+
(f'{namespace}.{key}', value or ''),
|
|
71
|
+
)
|
|
72
|
+
conn.commit()
|
|
73
|
+
conn.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# >>> all settings under a namespace as a {key: value} dict <<< #
|
|
77
|
+
def get_all(namespace: str) -> dict:
|
|
78
|
+
prefix = namespace + '.'
|
|
79
|
+
try:
|
|
80
|
+
conn = _conn()
|
|
81
|
+
rows = conn.execute(
|
|
82
|
+
'SELECT key, value FROM settings WHERE key LIKE ?', (prefix + '%',)
|
|
83
|
+
).fetchall()
|
|
84
|
+
conn.close()
|
|
85
|
+
return {r['key'][len(prefix):]: r['value'] for r in rows}
|
|
86
|
+
except Exception:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# >>> create the short-lived provider-token cache table <<< #
|
|
91
|
+
def init_token_cache():
|
|
92
|
+
os.makedirs(os.path.dirname(SETTINGS_DB), exist_ok=True)
|
|
93
|
+
conn = sqlite3.connect(SETTINGS_DB)
|
|
94
|
+
conn.execute(
|
|
95
|
+
'CREATE TABLE IF NOT EXISTS provider_tokens ('
|
|
96
|
+
' provider TEXT PRIMARY KEY, access_token TEXT NOT NULL,'
|
|
97
|
+
' expires_at TEXT NOT NULL, meta_json TEXT)'
|
|
98
|
+
)
|
|
99
|
+
conn.commit()
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# >>> cached bearer token for a provider, or None <<< #
|
|
104
|
+
def get_cached_token(provider: str):
|
|
105
|
+
try:
|
|
106
|
+
init_token_cache()
|
|
107
|
+
conn = _conn()
|
|
108
|
+
row = conn.execute(
|
|
109
|
+
'SELECT access_token, expires_at, meta_json FROM provider_tokens WHERE provider = ?',
|
|
110
|
+
(provider,),
|
|
111
|
+
).fetchone()
|
|
112
|
+
conn.close()
|
|
113
|
+
if row:
|
|
114
|
+
return {'access_token': row['access_token'], 'expires_at': row['expires_at'],
|
|
115
|
+
'meta': row['meta_json'] or ''}
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# >>> store a provider bearer token <<< #
|
|
122
|
+
def set_cached_token(provider: str, access_token: str, expires_at: str, meta: str = ''):
|
|
123
|
+
init_token_cache()
|
|
124
|
+
conn = _conn()
|
|
125
|
+
conn.execute(
|
|
126
|
+
'INSERT OR REPLACE INTO provider_tokens (provider, access_token, expires_at, meta_json)'
|
|
127
|
+
' VALUES (?, ?, ?, ?)',
|
|
128
|
+
(provider, access_token, expires_at, meta or ''),
|
|
129
|
+
)
|
|
130
|
+
conn.commit()
|
|
131
|
+
conn.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# >>> drop a provider's cached token <<< #
|
|
135
|
+
def clear_cached_token(provider: str):
|
|
136
|
+
try:
|
|
137
|
+
init_token_cache()
|
|
138
|
+
conn = _conn()
|
|
139
|
+
conn.execute('DELETE FROM provider_tokens WHERE provider = ?', (provider,))
|
|
140
|
+
conn.commit()
|
|
141
|
+
conn.close()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# >>> resolve a secret: service namespace > global > env > '' ; returns (value, source) <<< #
|
|
147
|
+
def resolve_secret(key: str, env_key: str, service_ns: str = None) -> tuple[str, str]:
|
|
148
|
+
ns = service_ns or SERVICE_NS
|
|
149
|
+
val = get_setting(ns, key)
|
|
150
|
+
if val:
|
|
151
|
+
return val, 'user'
|
|
152
|
+
val = get_setting('global', key)
|
|
153
|
+
if val:
|
|
154
|
+
return val, 'global'
|
|
155
|
+
val = os.environ.get(env_key, '')
|
|
156
|
+
if val:
|
|
157
|
+
return val, 'env'
|
|
158
|
+
return '', ''
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# >>> (token, source) for the Claude OAuth token <<< #
|
|
162
|
+
def resolve_claude_token(service_ns: str = None) -> tuple[str, str]:
|
|
163
|
+
return resolve_secret('claude_code_oauth_token', 'CLAUDE_CODE_OAUTH_TOKEN', service_ns)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# >>> (key, source) for the OpenAI API key <<< #
|
|
167
|
+
def resolve_openai_key(service_ns: str = None) -> tuple[str, str]:
|
|
168
|
+
return resolve_secret('openai_api_key', 'OPENAI_API_KEY', service_ns)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# >>> fixed-offset tz from OMI_TZ_OFFSET (hours, default +8) for day boundaries / schedulers <<< #
|
|
6
|
+
def local_tz() -> timezone:
|
|
7
|
+
try:
|
|
8
|
+
hours = int(os.environ.get('OMI_TZ_OFFSET', '8'))
|
|
9
|
+
except (ValueError, TypeError):
|
|
10
|
+
hours = 8
|
|
11
|
+
return timezone(timedelta(hours=hours))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# >>> current local datetime <<< #
|
|
15
|
+
def now_local() -> datetime:
|
|
16
|
+
return datetime.now(local_tz())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# >>> current local date <<< #
|
|
20
|
+
def today_local():
|
|
21
|
+
return now_local().date()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# >>> base URL from the incoming request, honoring X-Forwarded-Proto/Host (for feed/media links) <<< #
|
|
2
|
+
def request_base_url() -> str:
|
|
3
|
+
from flask import request
|
|
4
|
+
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
|
5
|
+
host = request.headers.get('X-Forwarded-Host', request.host)
|
|
6
|
+
return f'{scheme}://{host}'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oh-my-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared runtime for the oh-my-* Flask services: settings DB, Claude CLI gateway, timezone, base-url, scheduler.
|
|
5
|
+
Author-email: Dennis L <dennisl@udel.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ByteDennis/oh-my-common
|
|
8
|
+
Keywords: oh-my,flask,claude,settings
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: flask
|
|
15
|
+
Requires-Dist: flask>=3.0; extra == "flask"
|
|
16
|
+
Provides-Extra: scheduler
|
|
17
|
+
Requires-Dist: apscheduler>=3.10; extra == "scheduler"
|
|
18
|
+
|
|
19
|
+
# oh-my-common
|
|
20
|
+
|
|
21
|
+
Shared runtime for the `oh-my-*` Flask services. Extracted so each split-out
|
|
22
|
+
service (oh-my-rss / oh-my-msg / oh-my-write) shares one implementation instead
|
|
23
|
+
of drifting copies. Per the split design, each service keeps its **own**
|
|
24
|
+
settings DB — this package is the **code** layer, not a shared store.
|
|
25
|
+
|
|
26
|
+
## Modules
|
|
27
|
+
|
|
28
|
+
- **`settings`** — SQLite settings client + secret resolution.
|
|
29
|
+
`get_setting/put_setting/get_all`, the `provider_tokens` cache
|
|
30
|
+
(`get/set/clear_cached_token`), and `resolve_secret(key, env_key)` with
|
|
31
|
+
precedence **service-namespace > `global` > env**. Convenience:
|
|
32
|
+
`resolve_claude_token()`, `resolve_openai_key()`.
|
|
33
|
+
Config via env: `SETTINGS_DB` (path), `OMI_SERVICE_NS` (this service's
|
|
34
|
+
namespace, e.g. `clipboard`).
|
|
35
|
+
- **`claude`** — Claude CLI gateway: `resolve_claude_bin`, `claude_text`,
|
|
36
|
+
`run_claude_capture`, `gate_ai`, `claude_status`, `extract_json`,
|
|
37
|
+
`estimate_tokens`, `estimate_cost`, `MODEL_PRICING`. Flask-free.
|
|
38
|
+
- **`tz`** — `local_tz()/now_local()/today_local()` from `OMI_TZ_OFFSET` (hours, default +8).
|
|
39
|
+
- **`urls`** — `request_base_url()` (honors `X-Forwarded-Proto/Host`). Imports flask lazily.
|
|
40
|
+
- **`scheduler`** — `make_scheduler(ENABLED_ENV)` → a guarded `BackgroundScheduler`
|
|
41
|
+
(returns `None` when `ENABLED_ENV=0`, so extra gunicorn workers don't double-run).
|
|
42
|
+
Imports apscheduler lazily.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
os.environ.setdefault('OMI_SERVICE_NS', 'clipboard')
|
|
49
|
+
from oh_my_common import get_setting, claude_text, resolve_openai_key, now_local
|
|
50
|
+
|
|
51
|
+
key, src = resolve_openai_key()
|
|
52
|
+
text = claude_text('Summarize: ...', model='sonnet')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pip install oh-my-common # core (stdlib only)
|
|
59
|
+
pip install 'oh-my-common[flask,scheduler]' # + request_base_url + make_scheduler
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Distribution into Docker services is TBD — see the migration plan. Options:
|
|
63
|
+
publish to a registry, vendor into each monorepo, or a git dependency.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
oh_my_common/__init__.py
|
|
4
|
+
oh_my_common/claude.py
|
|
5
|
+
oh_my_common/scheduler.py
|
|
6
|
+
oh_my_common/settings.py
|
|
7
|
+
oh_my_common/tz.py
|
|
8
|
+
oh_my_common/urls.py
|
|
9
|
+
oh_my_common.egg-info/PKG-INFO
|
|
10
|
+
oh_my_common.egg-info/SOURCES.txt
|
|
11
|
+
oh_my_common.egg-info/dependency_links.txt
|
|
12
|
+
oh_my_common.egg-info/requires.txt
|
|
13
|
+
oh_my_common.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oh_my_common
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oh-my-common"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared runtime for the oh-my-* Flask services: settings DB, Claude CLI gateway, timezone, base-url, scheduler."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Dennis L", email = "dennisl@udel.edu" }]
|
|
13
|
+
keywords = ["oh-my", "flask", "claude", "settings"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Framework :: Flask",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/ByteDennis/oh-my-common"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
flask = ["flask>=3.0"]
|
|
26
|
+
scheduler = ["apscheduler>=3.10"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["oh_my_common"]
|