chatenv 0.2.6__tar.gz → 0.2.8__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 (32) hide show
  1. chatenv-0.2.8/PKG-INFO +160 -0
  2. chatenv-0.2.8/README.md +129 -0
  3. {chatenv-0.2.6 → chatenv-0.2.8}/pyproject.toml +4 -3
  4. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/__init__.py +3 -1
  5. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/cli.py +29 -5
  6. chatenv-0.2.8/src/chatenv/token_refreshers.py +146 -0
  7. chatenv-0.2.8/src/chatenv.egg-info/PKG-INFO +160 -0
  8. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv.egg-info/SOURCES.txt +4 -1
  9. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv.egg-info/requires.txt +2 -1
  10. {chatenv-0.2.6 → chatenv-0.2.8}/tests/test_chatenv_core.py +108 -8
  11. chatenv-0.2.8/tests/test_docs_contract.py +66 -0
  12. {chatenv-0.2.6 → chatenv-0.2.8}/tests/test_version.py +3 -2
  13. chatenv-0.2.8/tests/test_workflow_contract.py +46 -0
  14. chatenv-0.2.6/PKG-INFO +0 -182
  15. chatenv-0.2.6/README.md +0 -152
  16. chatenv-0.2.6/src/chatenv.egg-info/PKG-INFO +0 -182
  17. {chatenv-0.2.6 → chatenv-0.2.8}/LICENSE +0 -0
  18. {chatenv-0.2.6 → chatenv-0.2.8}/setup.cfg +0 -0
  19. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/configs.py +0 -0
  20. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/discovery.py +0 -0
  21. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/fields.py +0 -0
  22. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/paste.py +0 -0
  23. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/paths.py +0 -0
  24. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/presets/__init__.py +0 -0
  25. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/registry.py +0 -0
  26. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/source_chain.py +0 -0
  27. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/store.py +0 -0
  28. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/tokens.py +0 -0
  29. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv/utils.py +0 -0
  30. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv.egg-info/dependency_links.txt +0 -0
  31. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv.egg-info/entry_points.txt +0 -0
  32. {chatenv-0.2.6 → chatenv-0.2.8}/src/chatenv.egg-info/top_level.txt +0 -0
chatenv-0.2.8/PKG-INFO ADDED
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatenv
3
+ Version: 0.2.8
4
+ Summary: ChatArch typed environment profile manager
5
+ Author-email: rexwzh <1073853456@qq.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://arch.gh.wzhecnu.cn/ChatEnv/
8
+ Project-URL: Documentation, https://arch.gh.wzhecnu.cn/ChatEnv/
9
+ Project-URL: Repository, https://github.com/ChatArch/ChatEnv
10
+ Keywords: chatenv,chatarch,env
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: chatstyle<0.2.0,>=0.1.0
17
+ Requires-Dist: click<9.0,>=8.4.2
18
+ Requires-Dist: python-dotenv<2.0,>=1.2.2
19
+ Provides-Extra: dev
20
+ Requires-Dist: build>=1.0; extra == "dev"
21
+ Requires-Dist: pytest>=8.0; extra == "dev"
22
+ Requires-Dist: twine>=5.0; extra == "dev"
23
+ Provides-Extra: docs
24
+ Requires-Dist: mkdocs<2.0,>=1.6; extra == "docs"
25
+ Requires-Dist: mkdocs-material<10.0,>=9.5; extra == "docs"
26
+ Requires-Dist: mkdocs-static-i18n<2.0,>=1.2; extra == "docs"
27
+ Requires-Dist: mkdocs-minify-plugin<1.0,>=0.8; extra == "docs"
28
+ Requires-Dist: mkdocs-redirects<2.0,>=1.2; extra == "docs"
29
+ Requires-Dist: mike<3.0,>=2.1; extra == "docs"
30
+ Dynamic: license-file
31
+
32
+ <div align="center">
33
+ <a href="https://pypi.python.org/pypi/chatenv">
34
+ <img src="https://img.shields.io/pypi/v/chatenv.svg" alt="PyPI version" />
35
+ </a>
36
+ <a href="https://github.com/ChatArch/ChatEnv/actions/workflows/ci.yml">
37
+ <img src="https://github.com/ChatArch/ChatEnv/actions/workflows/ci.yml/badge.svg" alt="Tests" />
38
+ </a>
39
+ <a href="https://pypi.python.org/pypi/chatenv">
40
+ <img src="https://img.shields.io/pypi/pyversions/chatenv.svg" alt="Python versions" />
41
+ </a>
42
+ <a href="LICENSE">
43
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" />
44
+ </a>
45
+ </div>
46
+
47
+ <div align="center">
48
+
49
+ # ChatEnv
50
+
51
+ ChatArch typed env/profile runtime.
52
+
53
+ </div>
54
+
55
+ ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它提供字段描述、配置基类、registry、路径、profile 文件读写、mask、paste 解析,以及 runtime token-store 的通用能力;具体业务变量、登录刷新和连通性语义由各项目自己定义并注册。
56
+
57
+ 文档入口:https://arch.gh.wzhecnu.cn/ChatEnv/
58
+
59
+ ## 安装
60
+
61
+ ```bash
62
+ pip install chatenv --upgrade
63
+ chatenv --version
64
+ chatenv --tree
65
+ ```
66
+
67
+ 支持 Python `>=3.10`。
68
+
69
+ ## 目录
70
+
71
+ ```text
72
+ CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
73
+ $CHATARCH_HOME/envs/ # stable typed env/profile files
74
+ $CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
75
+ ```
76
+
77
+ ChatEnv 只负责 stable env/profile 与 runtime token-store 的存储规则,不额外创建 config/cache/data/state,也不把 `tokens/` 当作第二个手工维护 secret/env 层。
78
+
79
+ ## CLI 树
80
+
81
+ `chatenv --tree` 从当前安装包的 Click 注册表实时输出完整命令面:
82
+
83
+ ```text
84
+ chatenv [--home <HOME>] # Manage typed env profiles under $CHATARCH_HOME/envs.
85
+ ├── --help # Show this help message.
86
+ ├── --version # Show the installed package version.
87
+ ├── --tree # Print the registered command tree.
88
+ ├── init [--type <CONFIG-TYPES>] [--interactive/--no-interactive] # Create or update active typed env files.
89
+ ├── new [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Create a named typed profile without activating it.
90
+ ├── paste [--value <VALUE>] [--stdin] [--profile <PROFILE>] [--yes] [--interactive/--no-interactive] # Paste loose env text and import recognized keys.
91
+ ├── use [NAME] [--type <CONFIG-TYPES>] [--interactive/--no-interactive] # Activate a named profile for one config type.
92
+ ├── list [--type <CONFIG-TYPES>] # List active default and named profiles grouped by config type.
93
+ ├── status [--type <CONFIG-TYPES>] [--detail] # Show registered config platforms and provider ownership.
94
+ ├── token # Manage generic runtime token profiles.
95
+ │ ├── status <SERVICE> [PROFILE] [--format <OUTPUT-FORMAT>] # Show safe runtime token metadata for SERVICE/PROFILE.
96
+ │ ├── refresh <SERVICE> [PROFILE] [--format <OUTPUT-FORMAT>] # Refresh SERVICE/PROFILE through a registered service refresh provider.
97
+ │ ├── import <SERVICE> [PROFILE] [--stdin] [--file <VALUE-FILE>] [--token-type <TOKEN-TYPE>] [--summary <SUMMARY>] [--expires-at <EXPIRES-AT>] [--format <OUTPUT-FORMAT>] # Explicitly import externally refreshed runtime token JSON.
98
+ │ ├── list [SERVICE] [--format <OUTPUT-FORMAT>] # List runtime token profiles grouped by service.
99
+ │ └── clear <SERVICE> [PROFILE] [--execute] [--format <OUTPUT-FORMAT>] # Clear a generic runtime token file for SERVICE/PROFILE.
100
+ ├── cat [NAME] [--no-mask] [--type <CONFIG-TYPES>] # Print active values, or a named typed profile with -t TYPE NAME.
101
+ ├── get [KEY] [--interactive/--no-interactive] # Get a configuration value from active typed env files.
102
+ ├── set [KEY-VALUE] [--interactive/--no-interactive] # Set a configuration value in the matching active typed env file.
103
+ ├── save [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Save current active values as a named profile.
104
+ ├── delete [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Delete a named profile for one config type.
105
+ └── test [--target <TARGET>] [--interactive/--no-interactive] # Test a registered configuration schema.
106
+ ```
107
+
108
+ ## 常用命令
109
+
110
+ ```bash
111
+ chatenv init -t example
112
+ chatenv status --detail
113
+ chatenv cat -t example
114
+ chatenv paste --stdin --profile work --yes
115
+ chatenv set EXAMPLE_API_KEY=sk-xxx
116
+ chatenv get EXAMPLE_API_KEY
117
+ chatenv token refresh PyPI RexWzh
118
+ chatenv token status PyPI RexWzh
119
+ ```
120
+
121
+ 敏感值默认 mask;`token status/list/clear` 只输出 safe metadata,不输出 raw token/cookie/CSRF values。
122
+
123
+ ## Python API
124
+
125
+ ```python
126
+ from chatenv import BaseEnvConfig, EnvField, EnvStore, get_paths
127
+
128
+ class ExampleConfig(BaseEnvConfig):
129
+ _title = "Example Configuration"
130
+ _aliases = ["example"]
131
+ _storage_dir = "Example"
132
+
133
+ EXAMPLE_API_KEY = EnvField("EXAMPLE_API_KEY", is_sensitive=True)
134
+
135
+ paths = get_paths()
136
+ store = EnvStore(paths.envs_dir)
137
+ store.save_active(ExampleConfig, {"EXAMPLE_API_KEY": "sk-..."})
138
+ ```
139
+
140
+ ## 文档
141
+
142
+ - https://arch.gh.wzhecnu.cn/ChatEnv/
143
+ - `docs/cli.md`:CLI 用法
144
+ - `docs/design.md`:路径、数据布局与注册策略
145
+ - `docs/developer-guide.md`:chatxxx 项目接入和 provider 开发指南
146
+ - `docs/development.md`:测试、构建与发布
147
+
148
+ ## 开发
149
+
150
+ ```bash
151
+ python -m pip install -e .[dev,docs]
152
+ python -m pytest -q
153
+ python -m mkdocs build --strict
154
+ python -m build
155
+ python -m twine check dist/*
156
+ ```
157
+
158
+ ## 开源协议
159
+
160
+ MIT License
@@ -0,0 +1,129 @@
1
+ <div align="center">
2
+ <a href="https://pypi.python.org/pypi/chatenv">
3
+ <img src="https://img.shields.io/pypi/v/chatenv.svg" alt="PyPI version" />
4
+ </a>
5
+ <a href="https://github.com/ChatArch/ChatEnv/actions/workflows/ci.yml">
6
+ <img src="https://github.com/ChatArch/ChatEnv/actions/workflows/ci.yml/badge.svg" alt="Tests" />
7
+ </a>
8
+ <a href="https://pypi.python.org/pypi/chatenv">
9
+ <img src="https://img.shields.io/pypi/pyversions/chatenv.svg" alt="Python versions" />
10
+ </a>
11
+ <a href="LICENSE">
12
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" />
13
+ </a>
14
+ </div>
15
+
16
+ <div align="center">
17
+
18
+ # ChatEnv
19
+
20
+ ChatArch typed env/profile runtime.
21
+
22
+ </div>
23
+
24
+ ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它提供字段描述、配置基类、registry、路径、profile 文件读写、mask、paste 解析,以及 runtime token-store 的通用能力;具体业务变量、登录刷新和连通性语义由各项目自己定义并注册。
25
+
26
+ 文档入口:https://arch.gh.wzhecnu.cn/ChatEnv/
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ pip install chatenv --upgrade
32
+ chatenv --version
33
+ chatenv --tree
34
+ ```
35
+
36
+ 支持 Python `>=3.10`。
37
+
38
+ ## 目录
39
+
40
+ ```text
41
+ CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
42
+ $CHATARCH_HOME/envs/ # stable typed env/profile files
43
+ $CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
44
+ ```
45
+
46
+ ChatEnv 只负责 stable env/profile 与 runtime token-store 的存储规则,不额外创建 config/cache/data/state,也不把 `tokens/` 当作第二个手工维护 secret/env 层。
47
+
48
+ ## CLI 树
49
+
50
+ `chatenv --tree` 从当前安装包的 Click 注册表实时输出完整命令面:
51
+
52
+ ```text
53
+ chatenv [--home <HOME>] # Manage typed env profiles under $CHATARCH_HOME/envs.
54
+ ├── --help # Show this help message.
55
+ ├── --version # Show the installed package version.
56
+ ├── --tree # Print the registered command tree.
57
+ ├── init [--type <CONFIG-TYPES>] [--interactive/--no-interactive] # Create or update active typed env files.
58
+ ├── new [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Create a named typed profile without activating it.
59
+ ├── paste [--value <VALUE>] [--stdin] [--profile <PROFILE>] [--yes] [--interactive/--no-interactive] # Paste loose env text and import recognized keys.
60
+ ├── use [NAME] [--type <CONFIG-TYPES>] [--interactive/--no-interactive] # Activate a named profile for one config type.
61
+ ├── list [--type <CONFIG-TYPES>] # List active default and named profiles grouped by config type.
62
+ ├── status [--type <CONFIG-TYPES>] [--detail] # Show registered config platforms and provider ownership.
63
+ ├── token # Manage generic runtime token profiles.
64
+ │ ├── status <SERVICE> [PROFILE] [--format <OUTPUT-FORMAT>] # Show safe runtime token metadata for SERVICE/PROFILE.
65
+ │ ├── refresh <SERVICE> [PROFILE] [--format <OUTPUT-FORMAT>] # Refresh SERVICE/PROFILE through a registered service refresh provider.
66
+ │ ├── import <SERVICE> [PROFILE] [--stdin] [--file <VALUE-FILE>] [--token-type <TOKEN-TYPE>] [--summary <SUMMARY>] [--expires-at <EXPIRES-AT>] [--format <OUTPUT-FORMAT>] # Explicitly import externally refreshed runtime token JSON.
67
+ │ ├── list [SERVICE] [--format <OUTPUT-FORMAT>] # List runtime token profiles grouped by service.
68
+ │ └── clear <SERVICE> [PROFILE] [--execute] [--format <OUTPUT-FORMAT>] # Clear a generic runtime token file for SERVICE/PROFILE.
69
+ ├── cat [NAME] [--no-mask] [--type <CONFIG-TYPES>] # Print active values, or a named typed profile with -t TYPE NAME.
70
+ ├── get [KEY] [--interactive/--no-interactive] # Get a configuration value from active typed env files.
71
+ ├── set [KEY-VALUE] [--interactive/--no-interactive] # Set a configuration value in the matching active typed env file.
72
+ ├── save [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Save current active values as a named profile.
73
+ ├── delete [NAME] [--type <CONFIG-TYPES>] [--yes] [--interactive/--no-interactive] # Delete a named profile for one config type.
74
+ └── test [--target <TARGET>] [--interactive/--no-interactive] # Test a registered configuration schema.
75
+ ```
76
+
77
+ ## 常用命令
78
+
79
+ ```bash
80
+ chatenv init -t example
81
+ chatenv status --detail
82
+ chatenv cat -t example
83
+ chatenv paste --stdin --profile work --yes
84
+ chatenv set EXAMPLE_API_KEY=sk-xxx
85
+ chatenv get EXAMPLE_API_KEY
86
+ chatenv token refresh PyPI RexWzh
87
+ chatenv token status PyPI RexWzh
88
+ ```
89
+
90
+ 敏感值默认 mask;`token status/list/clear` 只输出 safe metadata,不输出 raw token/cookie/CSRF values。
91
+
92
+ ## Python API
93
+
94
+ ```python
95
+ from chatenv import BaseEnvConfig, EnvField, EnvStore, get_paths
96
+
97
+ class ExampleConfig(BaseEnvConfig):
98
+ _title = "Example Configuration"
99
+ _aliases = ["example"]
100
+ _storage_dir = "Example"
101
+
102
+ EXAMPLE_API_KEY = EnvField("EXAMPLE_API_KEY", is_sensitive=True)
103
+
104
+ paths = get_paths()
105
+ store = EnvStore(paths.envs_dir)
106
+ store.save_active(ExampleConfig, {"EXAMPLE_API_KEY": "sk-..."})
107
+ ```
108
+
109
+ ## 文档
110
+
111
+ - https://arch.gh.wzhecnu.cn/ChatEnv/
112
+ - `docs/cli.md`:CLI 用法
113
+ - `docs/design.md`:路径、数据布局与注册策略
114
+ - `docs/developer-guide.md`:chatxxx 项目接入和 provider 开发指南
115
+ - `docs/development.md`:测试、构建与发布
116
+
117
+ ## 开发
118
+
119
+ ```bash
120
+ python -m pip install -e .[dev,docs]
121
+ python -m pytest -q
122
+ python -m mkdocs build --strict
123
+ python -m build
124
+ python -m twine check dist/*
125
+ ```
126
+
127
+ ## 开源协议
128
+
129
+ MIT License
@@ -22,9 +22,9 @@ classifiers = [
22
22
  ]
23
23
 
24
24
  [project.urls]
25
- Homepage = "https://github.com/ChatArch/ChatEnv"
25
+ Homepage = "https://arch.gh.wzhecnu.cn/ChatEnv/"
26
+ Documentation = "https://arch.gh.wzhecnu.cn/ChatEnv/"
26
27
  Repository = "https://github.com/ChatArch/ChatEnv"
27
- Documentation = "https://chatarch.github.io/ChatEnv/"
28
28
 
29
29
  [tool.setuptools.dynamic]
30
30
  version = {attr = "chatenv.__version__"}
@@ -46,7 +46,8 @@ dev = [
46
46
  ]
47
47
  docs = [
48
48
  "mkdocs>=1.6,<2.0",
49
- "mkdocs-material>=9.5,<9.7",
49
+ "mkdocs-material>=9.5,<10.0",
50
+ "mkdocs-static-i18n>=1.2,<2.0",
50
51
  "mkdocs-minify-plugin>=0.8,<1.0",
51
52
  "mkdocs-redirects>=1.2,<2.0",
52
53
  "mike>=2.1,<3.0",
@@ -3,6 +3,7 @@
3
3
  from .fields import BaseEnvConfig, EnvField
4
4
  from .paths import ChatArchPaths, get_paths
5
5
  from .store import EnvStore
6
+ from .token_refreshers import TokenRefreshResult
6
7
  from .tokens import TokenStore
7
8
  from .configs import FeishuConfig, OpenAIConfig
8
9
  from .discovery import get_provider_configs, get_provider_errors, load_config_providers
@@ -14,6 +15,7 @@ __all__ = [
14
15
  "EnvStore",
15
16
  "FeishuConfig",
16
17
  "OpenAIConfig",
18
+ "TokenRefreshResult",
17
19
  "TokenStore",
18
20
  "get_provider_configs",
19
21
  "get_provider_errors",
@@ -22,4 +24,4 @@ __all__ = [
22
24
  "__version__",
23
25
  ]
24
26
 
25
- __version__ = "0.2.6"
27
+ __version__ = "0.2.8"
@@ -27,6 +27,7 @@ from .paste import iter_fields_for_values, parse_pasted_env_text
27
27
  from .paths import get_paths
28
28
  from .registry import resolve_config_types
29
29
  from .store import EnvStore
30
+ from .token_refreshers import refresh_token as refresh_runtime_token
30
31
  from .tokens import TokenStore
31
32
  from .utils import mask_secret
32
33
 
@@ -129,7 +130,7 @@ class OrderedGroup(click.Group):
129
130
 
130
131
 
131
132
  class TokenCommandGroup(OrderedGroup):
132
- command_order = ["status", "refresh", "list", "clear"]
133
+ command_order = ["status", "refresh", "import", "list", "clear"]
133
134
 
134
135
 
135
136
  def _format_metavar(name: str) -> str:
@@ -625,7 +626,7 @@ def _render_token_status(payload: dict[str, object]) -> None:
625
626
 
626
627
  def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str, object]:
627
628
  if read_stdin == bool(value_file):
628
- raise click.ClickException("token refresh requires exactly one of --stdin or --file.")
629
+ raise click.ClickException("token import requires exactly one of --stdin or --file.")
629
630
  text = sys.stdin.read() if read_stdin else value_file.read_text(encoding="utf-8") # type: ignore[union-attr]
630
631
  try:
631
632
  payload = json.loads(text)
@@ -639,6 +640,29 @@ def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str
639
640
  @token_group.command(name="refresh")
640
641
  @click.argument("service")
641
642
  @click.argument("profile", required=False, default="default")
643
+ @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
644
+ @click.pass_context
645
+ def token_refresh(ctx: click.Context, service: str, profile: str, output_format: str):
646
+ """Refresh SERVICE/PROFILE through a registered service refresh provider."""
647
+ try:
648
+ payload = refresh_runtime_token(
649
+ service,
650
+ profile,
651
+ home=ctx.obj["paths"].home_dir,
652
+ env_store=_store(ctx),
653
+ token_store=_token_store(ctx),
654
+ )
655
+ except ValueError as exc:
656
+ raise click.ClickException(str(exc)) from exc
657
+ if output_format == "json":
658
+ _echo_json(payload)
659
+ else:
660
+ _render_token_status(payload)
661
+
662
+
663
+ @token_group.command(name="import")
664
+ @click.argument("service")
665
+ @click.argument("profile", required=False, default="default")
642
666
  @click.option("--stdin", "read_stdin", is_flag=True, help="Read token JSON object from stdin.")
643
667
  @click.option("--file", "value_file", type=click.Path(dir_okay=False, path_type=Path), help="Read token JSON object from a file.")
644
668
  @click.option("--token-type", default="runtime", show_default=True, help="Caller-defined token/session type.")
@@ -646,7 +670,7 @@ def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str
646
670
  @click.option("--expires-at", default="", help="Optional caller-provided expiry timestamp.")
647
671
  @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
648
672
  @click.pass_context
649
- def token_refresh(
673
+ def token_import(
650
674
  ctx: click.Context,
651
675
  service: str,
652
676
  profile: str,
@@ -657,7 +681,7 @@ def token_refresh(
657
681
  expires_at: str,
658
682
  output_format: str,
659
683
  ):
660
- """Write refreshed generic runtime token JSON for SERVICE/PROFILE."""
684
+ """Explicitly import externally refreshed runtime token JSON."""
661
685
  values = _read_token_values(read_stdin=read_stdin, value_file=value_file)
662
686
  try:
663
687
  payload = _token_store(ctx).write(
@@ -667,7 +691,7 @@ def token_refresh(
667
691
  token_type=token_type,
668
692
  summary=_parse_summary(summary),
669
693
  expires_at=expires_at,
670
- source="refresh",
694
+ source="import",
671
695
  )
672
696
  except ValueError as exc:
673
697
  raise click.ClickException(str(exc)) from exc
@@ -0,0 +1,146 @@
1
+ """Service-owned runtime token refresh hooks.
2
+
3
+ ChatEnv owns token-store persistence and safe metadata rendering. Service
4
+ packages own authentication semantics. A service can register a refresh
5
+ provider through the ``chatenv.token_refreshers`` entry-point group; ChatEnv
6
+ then invokes that provider for ``chatenv token refresh SERVICE PROFILE`` and
7
+ writes the returned opaque runtime values into ``tokens/<Service>/<profile>.json``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from importlib.metadata import EntryPoint, entry_points
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Iterable, Mapping
16
+
17
+ from .paths import get_paths
18
+ from .store import EnvStore
19
+ from .tokens import DEFAULT_TOKEN_TYPE, TokenStore, normalize_service_name, normalize_token_profile
20
+
21
+ ENTRY_POINT_GROUP = "chatenv.token_refreshers"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TokenRefreshResult:
26
+ """Opaque values plus safe metadata returned by a service refresh hook."""
27
+
28
+ values: Mapping[str, Any]
29
+ token_type: str = DEFAULT_TOKEN_TYPE
30
+ summary: Mapping[str, Any] | None = None
31
+ expires_at: str | None = None
32
+
33
+
34
+ _token_refreshers: dict[str, Callable[..., Any]] = {}
35
+ _loaded = False
36
+
37
+
38
+ def _iter_refresh_entry_points() -> Iterable[EntryPoint]:
39
+ eps = entry_points()
40
+ if hasattr(eps, "select"):
41
+ return eps.select(group=ENTRY_POINT_GROUP)
42
+ return eps.get(ENTRY_POINT_GROUP, [])
43
+
44
+
45
+ def clear_token_refreshers() -> None:
46
+ """Clear the refresh-provider cache; intended for tests and reloads."""
47
+
48
+ global _loaded
49
+ _loaded = False
50
+ _token_refreshers.clear()
51
+
52
+
53
+ def _provider_callable(obj: Any) -> Callable[..., Any]:
54
+ if callable(obj):
55
+ return obj
56
+ for attr in ("refresh_token", "refresh"):
57
+ candidate = getattr(obj, attr, None)
58
+ if callable(candidate):
59
+ return candidate
60
+ raise TypeError("token refresh provider must be callable or expose refresh_token()/refresh()")
61
+
62
+
63
+ def load_token_refreshers(*, force: bool = False) -> dict[str, Callable[..., Any]]:
64
+ """Load service refresh hooks registered through entry points."""
65
+
66
+ global _loaded
67
+ if _loaded and not force:
68
+ return dict(_token_refreshers)
69
+ if force:
70
+ _token_refreshers.clear()
71
+ _loaded = True
72
+ for ep in _iter_refresh_entry_points():
73
+ service_name = normalize_service_name(ep.name)
74
+ provider = _provider_callable(ep.load())
75
+ _token_refreshers[service_name.lower()] = provider
76
+ return dict(_token_refreshers)
77
+
78
+
79
+ def get_token_refresher(service: str, *, force: bool = False) -> Callable[..., Any] | None:
80
+ service_name = normalize_service_name(service)
81
+ return load_token_refreshers(force=force).get(service_name.lower())
82
+
83
+
84
+ def _coerce_refresh_result(result: Any) -> TokenRefreshResult:
85
+ if isinstance(result, TokenRefreshResult):
86
+ return result
87
+ if isinstance(result, Mapping):
88
+ values = result.get("values")
89
+ if not isinstance(values, Mapping):
90
+ raise ValueError("token refresh provider result must include a non-empty values object")
91
+ return TokenRefreshResult(
92
+ values=values,
93
+ token_type=str(result.get("token_type") or DEFAULT_TOKEN_TYPE),
94
+ summary=result.get("summary") if isinstance(result.get("summary"), Mapping) else None,
95
+ expires_at=str(result.get("expires_at") or ""),
96
+ )
97
+ raise ValueError("token refresh provider must return TokenRefreshResult or a mapping")
98
+
99
+
100
+ def refresh_token(
101
+ service: str,
102
+ profile: str | None = None,
103
+ *,
104
+ home: str | Path | None = None,
105
+ env_store: EnvStore | None = None,
106
+ token_store: TokenStore | None = None,
107
+ ) -> dict[str, Any]:
108
+ """Invoke the registered service refresh hook and persist its token values."""
109
+
110
+ service_name = normalize_service_name(service)
111
+ profile_name = normalize_token_profile(profile)
112
+ provider = get_token_refresher(service_name)
113
+ if provider is None:
114
+ raise ValueError(f"No token refresh provider registered for {service_name}")
115
+
116
+ paths = get_paths(home)
117
+ env_store = env_store or EnvStore(paths.envs_dir)
118
+ token_store = token_store or TokenStore(tokens_dir=paths.tokens_dir)
119
+ result = _coerce_refresh_result(
120
+ provider(
121
+ service=service_name,
122
+ profile=profile_name,
123
+ home=paths.home_dir,
124
+ env_store=env_store,
125
+ token_store=token_store,
126
+ )
127
+ )
128
+ return token_store.write(
129
+ service_name,
130
+ profile_name,
131
+ values=dict(result.values),
132
+ token_type=result.token_type or DEFAULT_TOKEN_TYPE,
133
+ summary=dict(result.summary or {}),
134
+ expires_at=result.expires_at or "",
135
+ source="refresh",
136
+ )
137
+
138
+
139
+ __all__ = [
140
+ "ENTRY_POINT_GROUP",
141
+ "TokenRefreshResult",
142
+ "clear_token_refreshers",
143
+ "get_token_refresher",
144
+ "load_token_refreshers",
145
+ "refresh_token",
146
+ ]