chatenv 0.2.5__tar.gz → 0.2.7__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 (27) hide show
  1. {chatenv-0.2.5/src/chatenv.egg-info → chatenv-0.2.7}/PKG-INFO +14 -2
  2. {chatenv-0.2.5 → chatenv-0.2.7}/README.md +13 -1
  3. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/__init__.py +3 -1
  4. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/cli.py +48 -15
  5. chatenv-0.2.7/src/chatenv/token_refreshers.py +146 -0
  6. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/tokens.py +21 -1
  7. {chatenv-0.2.5 → chatenv-0.2.7/src/chatenv.egg-info}/PKG-INFO +14 -2
  8. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/SOURCES.txt +1 -0
  9. {chatenv-0.2.5 → chatenv-0.2.7}/tests/test_chatenv_core.py +151 -8
  10. {chatenv-0.2.5 → chatenv-0.2.7}/tests/test_version.py +3 -2
  11. {chatenv-0.2.5 → chatenv-0.2.7}/LICENSE +0 -0
  12. {chatenv-0.2.5 → chatenv-0.2.7}/pyproject.toml +0 -0
  13. {chatenv-0.2.5 → chatenv-0.2.7}/setup.cfg +0 -0
  14. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/configs.py +0 -0
  15. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/discovery.py +0 -0
  16. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/fields.py +0 -0
  17. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/paste.py +0 -0
  18. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/paths.py +0 -0
  19. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/presets/__init__.py +0 -0
  20. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/registry.py +0 -0
  21. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/source_chain.py +0 -0
  22. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/store.py +0 -0
  23. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/utils.py +0 -0
  24. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/dependency_links.txt +0 -0
  25. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/entry_points.txt +0 -0
  26. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/requires.txt +0 -0
  27. {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -68,7 +68,8 @@ pip install -e .[dev] # 仓库开发
68
68
 
69
69
  ```text
70
70
  CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
71
- $CHATARCH_HOME/envs/
71
+ $CHATARCH_HOME/envs/ # stable typed env/profile files
72
+ $CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
72
73
  ```
73
74
 
74
75
  实际布局由业务项目注册的 schema 决定,例如:
@@ -122,6 +123,17 @@ export CHATARCH_AUTO_PROMPT=false
122
123
 
123
124
  更多用法见 `docs/cli.md`。
124
125
 
126
+ ## Runtime tokens
127
+
128
+ ChatEnv 区分 stable env/profile 和动态 runtime token:
129
+
130
+ - `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
131
+ - `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
132
+ - `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
133
+ - 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
134
+
135
+ ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
136
+
125
137
  ## Paste
126
138
 
127
139
  `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
@@ -38,7 +38,8 @@ pip install -e .[dev] # 仓库开发
38
38
 
39
39
  ```text
40
40
  CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
41
- $CHATARCH_HOME/envs/
41
+ $CHATARCH_HOME/envs/ # stable typed env/profile files
42
+ $CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
42
43
  ```
43
44
 
44
45
  实际布局由业务项目注册的 schema 决定,例如:
@@ -92,6 +93,17 @@ export CHATARCH_AUTO_PROMPT=false
92
93
 
93
94
  更多用法见 `docs/cli.md`。
94
95
 
96
+ ## Runtime tokens
97
+
98
+ ChatEnv 区分 stable env/profile 和动态 runtime token:
99
+
100
+ - `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
101
+ - `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
102
+ - `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
103
+ - 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
104
+
105
+ ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
106
+
95
107
  ## Paste
96
108
 
97
109
  `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
@@ -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.5"
27
+ __version__ = "0.2.7"
@@ -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,17 +681,20 @@ 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
- payload = _token_store(ctx).write(
663
- service,
664
- profile,
665
- values=values,
666
- token_type=token_type,
667
- summary=_parse_summary(summary),
668
- expires_at=expires_at,
669
- source="refresh",
670
- )
686
+ try:
687
+ payload = _token_store(ctx).write(
688
+ service,
689
+ profile,
690
+ values=values,
691
+ token_type=token_type,
692
+ summary=_parse_summary(summary),
693
+ expires_at=expires_at,
694
+ source="import",
695
+ )
696
+ except ValueError as exc:
697
+ raise click.ClickException(str(exc)) from exc
671
698
  if output_format == "json":
672
699
  _echo_json(payload)
673
700
  else:
@@ -681,7 +708,10 @@ def token_refresh(
681
708
  @click.pass_context
682
709
  def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
683
710
  """Show safe runtime token metadata for SERVICE/PROFILE."""
684
- payload = _token_store(ctx).status(service, profile)
711
+ try:
712
+ payload = _token_store(ctx).status(service, profile)
713
+ except ValueError as exc:
714
+ raise click.ClickException(str(exc)) from exc
685
715
  if output_format == "json":
686
716
  _echo_json(payload)
687
717
  else:
@@ -721,7 +751,10 @@ def token_list(ctx: click.Context, service: str | None, output_format: str):
721
751
  @click.pass_context
722
752
  def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
723
753
  """Clear a generic runtime token file for SERVICE/PROFILE."""
724
- payload = _token_store(ctx).clear(service, profile, execute=execute)
754
+ try:
755
+ payload = _token_store(ctx).clear(service, profile, execute=execute)
756
+ except ValueError as exc:
757
+ raise click.ClickException(str(exc)) from exc
725
758
  if output_format == "json":
726
759
  _echo_json(payload)
727
760
  else:
@@ -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
+ ]
@@ -36,12 +36,32 @@ def _safe_segment(value: str, *, default: str) -> str:
36
36
  return cleaned or default
37
37
 
38
38
 
39
+ def _strict_token_profile_segment(value: str | None) -> str:
40
+ if value is None:
41
+ return DEFAULT_PROFILE
42
+ raw = str(value)
43
+ stripped = raw.strip()
44
+ if not stripped:
45
+ raise ValueError("token profile must not be empty")
46
+ if stripped != raw:
47
+ raise ValueError("token profile must not contain leading or trailing whitespace")
48
+ if stripped in {".", ".."}:
49
+ raise ValueError("token profile must not be '.' or '..'")
50
+ if "/" in stripped or "\\" in stripped:
51
+ raise ValueError("token profile must be a single path segment")
52
+ if re.search(r"[^A-Za-z0-9_.-]", stripped):
53
+ raise ValueError("token profile may contain only letters, numbers, dot, underscore, and hyphen")
54
+ if stripped.strip(".-") != stripped:
55
+ raise ValueError("token profile must not start or end with dot or hyphen")
56
+ return stripped
57
+
58
+
39
59
  def normalize_service_name(value: str) -> str:
40
60
  return _safe_segment(value, default="Service")
41
61
 
42
62
 
43
63
  def normalize_token_profile(value: str | None) -> str:
44
- return _safe_segment(value or DEFAULT_PROFILE, default=DEFAULT_PROFILE)
64
+ return _strict_token_profile_segment(value)
45
65
 
46
66
 
47
67
  def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -68,7 +68,8 @@ pip install -e .[dev] # 仓库开发
68
68
 
69
69
  ```text
70
70
  CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
71
- $CHATARCH_HOME/envs/
71
+ $CHATARCH_HOME/envs/ # stable typed env/profile files
72
+ $CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
72
73
  ```
73
74
 
74
75
  实际布局由业务项目注册的 schema 决定,例如:
@@ -122,6 +123,17 @@ export CHATARCH_AUTO_PROMPT=false
122
123
 
123
124
  更多用法见 `docs/cli.md`。
124
125
 
126
+ ## Runtime tokens
127
+
128
+ ChatEnv 区分 stable env/profile 和动态 runtime token:
129
+
130
+ - `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
131
+ - `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
132
+ - `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
133
+ - 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
134
+
135
+ ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
136
+
125
137
  ## Paste
126
138
 
127
139
  `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
@@ -11,6 +11,7 @@ src/chatenv/paths.py
11
11
  src/chatenv/registry.py
12
12
  src/chatenv/source_chain.py
13
13
  src/chatenv/store.py
14
+ src/chatenv/token_refreshers.py
14
15
  src/chatenv/tokens.py
15
16
  src/chatenv/utils.py
16
17
  src/chatenv.egg-info/PKG-INFO
@@ -2,6 +2,7 @@ import json
2
2
  from pathlib import Path
3
3
  from types import SimpleNamespace
4
4
 
5
+ import pytest
5
6
  from click.testing import CliRunner
6
7
  import chatenv.discovery as discovery_module
7
8
  import chatenv.cli as cli_module
@@ -294,6 +295,147 @@ def test_token_store_list_and_clear_are_generic(tmp_path):
294
295
  assert store.status("PyPI", "RexWzh")["token_present"] is False
295
296
 
296
297
 
298
+ def test_token_store_rejects_aliasing_profile_segments(tmp_path):
299
+ from chatenv.tokens import TokenStore, normalize_token_profile
300
+
301
+ store = TokenStore(home=tmp_path / "arch")
302
+ store.write("PyPI", "RexWzh", values={"session": "opaque-value"}, token_type="web_session")
303
+
304
+ invalid_profiles = [
305
+ "",
306
+ " ",
307
+ ".",
308
+ "..",
309
+ "../RexWzh",
310
+ "RexWzh/other",
311
+ "RexWzh\\other",
312
+ " RexWzh",
313
+ "RexWzh ",
314
+ ".RexWzh",
315
+ "RexWzh.",
316
+ "Rex Wzh",
317
+ ]
318
+ for profile in invalid_profiles:
319
+ with pytest.raises(ValueError):
320
+ normalize_token_profile(profile)
321
+ with pytest.raises(ValueError):
322
+ store.status("PyPI", profile)
323
+
324
+ assert store.status("PyPI", "RexWzh")["token_present"] is True
325
+ assert store.status("PyPI", None)["profile"] == "default"
326
+
327
+
328
+ def test_token_cli_reports_invalid_profile_without_aliasing(tmp_path):
329
+ runner = CliRunner()
330
+ result = runner.invoke(
331
+ cli,
332
+ ["--home", str(tmp_path / "arch"), "token", "status", "PyPI", "../RexWzh", "--format", "json"],
333
+ )
334
+
335
+ assert result.exit_code != 0
336
+ assert "single path segment" in result.output
337
+ assert "Traceback" not in result.output
338
+
339
+
340
+ def test_token_refresh_uses_registered_provider_not_manual_json(monkeypatch, tmp_path):
341
+ from chatenv import token_refreshers as refresh_module
342
+ from chatenv.token_refreshers import TokenRefreshResult
343
+ from chatenv.tokens import TokenStore
344
+
345
+ runner = CliRunner()
346
+ home = tmp_path / "arch"
347
+ calls = []
348
+
349
+ def refresh(**kwargs):
350
+ calls.append(kwargs)
351
+ assert kwargs["service"] == "PyPI"
352
+ assert kwargs["profile"] == "RexWzh"
353
+ assert kwargs["home"] == home
354
+ return TokenRefreshResult(
355
+ values={"session": "opaque-value"},
356
+ token_type="web_session",
357
+ summary={"username": "operator"},
358
+ expires_at="2026-08-11T23:00:00Z",
359
+ )
360
+
361
+ class FakeEntryPoint:
362
+ name = "PyPI"
363
+ value = "tests.fake:refresh"
364
+
365
+ def load(self):
366
+ return refresh
367
+
368
+ monkeypatch.setattr(refresh_module, "_iter_refresh_entry_points", lambda: [FakeEntryPoint()])
369
+ refresh_module.clear_token_refreshers()
370
+
371
+ result = runner.invoke(
372
+ cli,
373
+ ["--home", str(home), "token", "refresh", "PyPI", "RexWzh", "--format", "json"],
374
+ )
375
+
376
+ assert result.exit_code == 0, result.output
377
+ assert len(calls) == 1
378
+ payload = json.loads(result.output)
379
+ assert payload["service"] == "PyPI"
380
+ assert payload["profile"] == "RexWzh"
381
+ assert payload["token_type"] == "web_session"
382
+ assert payload["source"] == "refresh"
383
+ assert payload["summary"] == {"username": "operator"}
384
+ assert "opaque-value" not in result.output
385
+ assert TokenStore(home=home).read("PyPI", "RexWzh")["values"] == {"session": "opaque-value"}
386
+
387
+
388
+ def test_token_refresh_without_provider_does_not_accept_manual_payload(monkeypatch, tmp_path):
389
+ from chatenv import token_refreshers as refresh_module
390
+
391
+ runner = CliRunner()
392
+ home = tmp_path / "arch"
393
+ monkeypatch.setattr(refresh_module, "_iter_refresh_entry_points", lambda: [])
394
+ refresh_module.clear_token_refreshers()
395
+
396
+ result = runner.invoke(
397
+ cli,
398
+ ["--home", str(home), "token", "refresh", "PyPI", "RexWzh", "--format", "json"],
399
+ )
400
+
401
+ assert result.exit_code != 0
402
+ assert "No token refresh provider registered for PyPI" in result.output
403
+ assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
404
+
405
+
406
+ def test_token_import_is_explicit_manual_json_path(tmp_path):
407
+ from chatenv.tokens import TokenStore
408
+
409
+ runner = CliRunner()
410
+ home = tmp_path / "arch"
411
+ result = runner.invoke(
412
+ cli,
413
+ [
414
+ "--home",
415
+ str(home),
416
+ "token",
417
+ "import",
418
+ "PyPI",
419
+ "RexWzh",
420
+ "--stdin",
421
+ "--token-type",
422
+ "web_session",
423
+ "--summary",
424
+ "username=operator",
425
+ "--format",
426
+ "json",
427
+ ],
428
+ input=json.dumps({"session": "opaque-value"}),
429
+ )
430
+
431
+ assert result.exit_code == 0, result.output
432
+ payload = json.loads(result.output)
433
+ assert payload["source"] == "import"
434
+ assert payload["summary"] == {"username": "operator"}
435
+ assert "opaque-value" not in result.output
436
+ assert TokenStore(home=home).read("PyPI", "RexWzh")["values"] == {"session": "opaque-value"}
437
+
438
+
297
439
  def test_store_profile_roundtrip(tmp_path):
298
440
  store = EnvStore(tmp_path / "envs")
299
441
  UnitConfig.load_from_sources(env_values={"UNIT_KEY": "secret", "UNIT_VALUE": "x"})
@@ -658,18 +800,18 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
658
800
  assert "Unit (unit)" in result.output
659
801
 
660
802
 
661
- def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
803
+ def test_cli_token_import_status_list_and_clear_hide_values(tmp_path):
662
804
  runner = CliRunner()
663
805
  home = tmp_path / "arch"
664
806
  token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
665
807
 
666
- refreshed = runner.invoke(
808
+ imported = runner.invoke(
667
809
  cli,
668
810
  [
669
811
  "--home",
670
812
  str(home),
671
813
  "token",
672
- "refresh",
814
+ "import",
673
815
  "PyPI",
674
816
  "RexWzh",
675
817
  "--token-type",
@@ -685,11 +827,12 @@ def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
685
827
  input=token_json,
686
828
  )
687
829
 
688
- assert refreshed.exit_code == 0, refreshed.output
689
- assert '"token_present": true' in refreshed.output
690
- assert '"token_file"' in refreshed.output
691
- assert "opaque-value-a" not in refreshed.output
692
- assert "opaque-value-b" not in refreshed.output
830
+ assert imported.exit_code == 0, imported.output
831
+ assert '"source": "import"' in imported.output
832
+ assert '"token_present": true' in imported.output
833
+ assert '"token_file"' in imported.output
834
+ assert "opaque-value-a" not in imported.output
835
+ assert "opaque-value-b" not in imported.output
693
836
  assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
694
837
 
695
838
  status = runner.invoke(
@@ -4,7 +4,7 @@ from click.testing import CliRunner
4
4
 
5
5
 
6
6
  def test_version_present():
7
- assert __version__ == "0.2.5"
7
+ assert __version__ == "0.2.7"
8
8
 
9
9
 
10
10
  def test_cli_version_option():
@@ -36,8 +36,9 @@ def test_tree_option_renders_registered_command_surface():
36
36
  assert "├── list" in result.output
37
37
  assert "├── status" in result.output
38
38
  assert "├── token" in result.output
39
- assert "│ ├── refresh" in result.output
40
39
  assert "│ ├── status" in result.output
40
+ assert "│ ├── refresh" in result.output
41
+ assert "│ ├── import" in result.output
41
42
  assert "│ ├── list" in result.output
42
43
  assert "│ └── clear" in result.output
43
44
  assert "├── cat [NAME]" in result.output
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes