chatenv 0.1.2__tar.gz → 0.2.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 (26) hide show
  1. {chatenv-0.1.2/src/chatenv.egg-info → chatenv-0.2.0}/PKG-INFO +5 -4
  2. {chatenv-0.1.2 → chatenv-0.2.0}/README.md +3 -2
  3. {chatenv-0.1.2 → chatenv-0.2.0}/pyproject.toml +1 -1
  4. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/__init__.py +4 -1
  5. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/cli.py +10 -7
  6. chatenv-0.2.0/src/chatenv/configs.py +103 -0
  7. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/fields.py +15 -2
  8. chatenv-0.2.0/src/chatenv/paste.py +136 -0
  9. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/store.py +28 -3
  10. {chatenv-0.1.2 → chatenv-0.2.0/src/chatenv.egg-info}/PKG-INFO +5 -4
  11. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/SOURCES.txt +1 -0
  12. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/requires.txt +1 -1
  13. {chatenv-0.1.2 → chatenv-0.2.0}/tests/test_chatenv_core.py +235 -0
  14. {chatenv-0.1.2 → chatenv-0.2.0}/tests/test_version.py +1 -1
  15. chatenv-0.1.2/src/chatenv/paste.py +0 -75
  16. {chatenv-0.1.2 → chatenv-0.2.0}/LICENSE +0 -0
  17. {chatenv-0.1.2 → chatenv-0.2.0}/setup.cfg +0 -0
  18. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/discovery.py +0 -0
  19. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/paths.py +0 -0
  20. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/presets/__init__.py +0 -0
  21. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/registry.py +0 -0
  22. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/source_chain.py +0 -0
  23. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/utils.py +0 -0
  24. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/dependency_links.txt +0 -0
  25. {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/entry_points.txt +0 -0
  26. {chatenv-0.1.2 → chatenv-0.2.0}/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.1.2
3
+ Version: 0.2.0
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -10,7 +10,7 @@ Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: chatstyle>=0.1.0
13
+ Requires-Dist: chatstyle<0.2.0,>=0.1.0
14
14
  Requires-Dist: click>=8.0
15
15
  Requires-Dist: python-dotenv>=1.0
16
16
  Provides-Extra: dev
@@ -48,7 +48,7 @@ ChatArch typed env/profile runtime.
48
48
 
49
49
  </div>
50
50
 
51
- ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它只提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;具体变量由 ChatTool、ChatDNS 等项目自己定义并注册。
51
+ ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;同时内置少量跨工具共享 schema(当前为 OpenAI / Feishu)。工具私有变量仍由 ChatTool、ChatDNS 等项目自己定义并注册。
52
52
 
53
53
  当前设计保持减法:只使用一个根变量 `CHATARCH_HOME`,只管理 env/profile 文件,不额外创建 config/cache/data/state。
54
54
 
@@ -115,12 +115,13 @@ export CHATARCH_AUTO_PROMPT=false
115
115
 
116
116
  ## Paste
117
117
 
118
- `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key
118
+ `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
119
119
 
120
120
  ```bash
121
121
  chatenv paste
122
122
  chatenv paste --stdin --profile work
123
123
  chatenv paste --value "EXAMPLE_API_KEY='sk-xxx'" --yes
124
+ chatenv paste --value "EXAMPLE_MODEL='gpt example' EXAMPLE_API_KEY='sk-xxx'" --yes
124
125
  ```
125
126
 
126
127
  写入前会输出识别概要:识别到哪些类型、哪些 key、未知 key 被忽略。
@@ -21,7 +21,7 @@ ChatArch typed env/profile runtime.
21
21
 
22
22
  </div>
23
23
 
24
- ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它只提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;具体变量由 ChatTool、ChatDNS 等项目自己定义并注册。
24
+ ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;同时内置少量跨工具共享 schema(当前为 OpenAI / Feishu)。工具私有变量仍由 ChatTool、ChatDNS 等项目自己定义并注册。
25
25
 
26
26
  当前设计保持减法:只使用一个根变量 `CHATARCH_HOME`,只管理 env/profile 文件,不额外创建 config/cache/data/state。
27
27
 
@@ -88,12 +88,13 @@ export CHATARCH_AUTO_PROMPT=false
88
88
 
89
89
  ## Paste
90
90
 
91
- `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key
91
+ `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
92
92
 
93
93
  ```bash
94
94
  chatenv paste
95
95
  chatenv paste --stdin --profile work
96
96
  chatenv paste --value "EXAMPLE_API_KEY='sk-xxx'" --yes
97
+ chatenv paste --value "EXAMPLE_MODEL='gpt example' EXAMPLE_API_KEY='sk-xxx'" --yes
97
98
  ```
98
99
 
99
100
  写入前会输出识别概要:识别到哪些类型、哪些 key、未知 key 被忽略。
@@ -12,7 +12,7 @@ license = "MIT"
12
12
  authors = [{name = "rexwzh", email = "1073853456@qq.com"}]
13
13
  keywords = ["chatenv", "chatarch", "env"]
14
14
  dependencies = [
15
- "chatstyle>=0.1.0",
15
+ "chatstyle>=0.1.0,<0.2.0",
16
16
  "click>=8.0",
17
17
  "python-dotenv>=1.0",
18
18
  ]
@@ -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 .configs import FeishuConfig, OpenAIConfig
6
7
  from .discovery import get_provider_configs, get_provider_errors, load_config_providers
7
8
 
8
9
  __all__ = [
@@ -10,6 +11,8 @@ __all__ = [
10
11
  "ChatArchPaths",
11
12
  "EnvField",
12
13
  "EnvStore",
14
+ "FeishuConfig",
15
+ "OpenAIConfig",
13
16
  "get_provider_configs",
14
17
  "get_provider_errors",
15
18
  "get_paths",
@@ -17,4 +20,4 @@ __all__ = [
17
20
  "__version__",
18
21
  ]
19
22
 
20
- __version__ = "0.1.2"
23
+ __version__ = "0.2.0"
@@ -515,14 +515,14 @@ def set_env(ctx: click.Context, key_value: str | None, interactive: bool | None)
515
515
  click.echo("Error: Invalid format. Use KEY=VALUE", err=True)
516
516
  return
517
517
  key, value = key_value.split("=", 1)
518
- _load_all(ctx)
519
518
  match = BaseEnvConfig.find_field(key.strip())
520
519
  if match is None:
521
520
  click.echo(f"Error: Key '{key.strip()}' not found", err=True)
522
521
  return
523
- config_cls, _ = match
524
- BaseEnvConfig.set(key.strip(), value.strip())
525
- _write_active(ctx, config_cls)
522
+ config_cls, field = match
523
+ existing_values = _store(ctx).load_active(config_cls)
524
+ existing_values[field.env_key] = value.strip()
525
+ _store(ctx).save_active(config_cls, existing_values)
526
526
  click.echo(f"Set {key.strip()}={value.strip()}")
527
527
 
528
528
 
@@ -699,12 +699,15 @@ def paste_env(ctx: click.Context, value: str | None, read_stdin: bool, profile:
699
699
  interactive,
700
700
  )
701
701
 
702
- _load_all(ctx)
703
- _apply_values(result.grouped)
704
702
  store = _store(ctx)
705
703
  click.echo("Written values:")
706
704
  for config_cls, values in result.grouped.items():
707
- target = store.save_profile(config_cls, profile_name) if profile_name else store.save_active(config_cls)
705
+ if profile_name:
706
+ target = store.save_profile(config_cls, profile_name, values)
707
+ else:
708
+ existing_values = store.load_active(config_cls)
709
+ existing_values.update(values)
710
+ target = store.save_active(config_cls, existing_values)
708
711
  click.echo(f"- {config_cls.get_storage_name()}: {target}")
709
712
  for field, _ in iter_fields_for_values(config_cls, values):
710
713
  click.echo(f" - {field.env_key}")
@@ -0,0 +1,103 @@
1
+ """Built-in shared ChatArch env schemas.
2
+
3
+ Only broadly shared configuration schemas live here. Package-specific schemas
4
+ should stay in their owning package and register through ``chatenv.configs``
5
+ entry points.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .fields import BaseEnvConfig, EnvField
11
+
12
+
13
+ class OpenAIConfig(BaseEnvConfig):
14
+ """Shared OpenAI-compatible model provider configuration."""
15
+
16
+ _title = "OpenAI Configuration"
17
+ _aliases = ["oai", "openai"]
18
+ _storage_dir = "OpenAI"
19
+ _order = 10
20
+
21
+ OPENAI_API_BASE = EnvField(
22
+ "OPENAI_API_BASE",
23
+ desc="The base URL of the API, usually with suffix /v1.",
24
+ )
25
+ OPENAI_API_KEY = EnvField(
26
+ "OPENAI_API_KEY",
27
+ desc="OpenAI-compatible API key",
28
+ is_sensitive=True,
29
+ )
30
+ OPENAI_API_MODEL = EnvField(
31
+ "OPENAI_API_MODEL",
32
+ default="gpt-5.5",
33
+ desc="Default model name",
34
+ )
35
+ OPENAI_ACCESS_TOKEN = EnvField(
36
+ "OPENAI_ACCESS_TOKEN",
37
+ desc="OpenAI OAuth access token for OAuth-backed capabilities.",
38
+ is_sensitive=True,
39
+ )
40
+ OPENAI_REFRESH_TOKEN = EnvField(
41
+ "OPENAI_REFRESH_TOKEN",
42
+ desc="OpenAI OAuth refresh token for OAuth-backed capabilities.",
43
+ is_sensitive=True,
44
+ )
45
+ OPENAI_OAUTH_BASE_URL = EnvField(
46
+ "OPENAI_OAUTH_BASE_URL",
47
+ default="https://auth.openai.com",
48
+ desc="OpenAI OAuth auth server base URL used to refresh access tokens.",
49
+ )
50
+ OPENAI_ACCESS_TOKEN_EXPIRES_AT = EnvField(
51
+ "OPENAI_ACCESS_TOKEN_EXPIRES_AT",
52
+ desc="UTC ISO timestamp when the OpenAI OAuth access token expires.",
53
+ )
54
+ OPENAI_IMAGE_MODEL = EnvField(
55
+ "OPENAI_IMAGE_MODEL",
56
+ default="gpt-image-2-medium",
57
+ desc="Default OpenAI image model preset.",
58
+ )
59
+
60
+
61
+ class FeishuConfig(BaseEnvConfig):
62
+ """Shared Feishu/Lark app and bot configuration."""
63
+
64
+ _title = "Feishu Configuration"
65
+ _aliases = ["feishu", "lark"]
66
+ _storage_dir = "Feishu"
67
+ _order = 20
68
+
69
+ FEISHU_APP_ID = EnvField(
70
+ "FEISHU_APP_ID",
71
+ desc="Feishu/Lark app ID from the developer console.",
72
+ )
73
+ FEISHU_APP_SECRET = EnvField(
74
+ "FEISHU_APP_SECRET",
75
+ desc="Feishu/Lark app secret.",
76
+ is_sensitive=True,
77
+ )
78
+ FEISHU_API_BASE = EnvField(
79
+ "FEISHU_API_BASE",
80
+ default="https://open.feishu.cn",
81
+ desc="Feishu/Lark API base URL. Use https://open.larksuite.com for Lark.",
82
+ )
83
+ FEISHU_DEFAULT_RECEIVER_ID = EnvField(
84
+ "FEISHU_DEFAULT_RECEIVER_ID",
85
+ desc="Default user receive_id for Feishu/Lark sends.",
86
+ )
87
+ FEISHU_DEFAULT_CHAT_ID = EnvField(
88
+ "FEISHU_DEFAULT_CHAT_ID",
89
+ desc="Default chat_id for Feishu/Lark chat sends.",
90
+ )
91
+ FEISHU_ENCRYPT_KEY = EnvField(
92
+ "FEISHU_ENCRYPT_KEY",
93
+ desc="Feishu/Lark event encrypt key.",
94
+ is_sensitive=True,
95
+ )
96
+ FEISHU_VERIFY_TOKEN = EnvField(
97
+ "FEISHU_VERIFY_TOKEN",
98
+ desc="Feishu/Lark event verification token.",
99
+ is_sensitive=True,
100
+ )
101
+
102
+
103
+ __all__ = ["OpenAIConfig", "FeishuConfig"]
@@ -38,14 +38,27 @@ class BaseEnvConfig:
38
38
  """Base class for typed env/profile schemas."""
39
39
 
40
40
  _registry: ClassVar[list[type["BaseEnvConfig"]]] = []
41
+ _registry_keys: ClassVar[dict[str, type["BaseEnvConfig"]]] = {}
41
42
  _title: ClassVar[str] = "Configuration"
42
43
  _aliases: ClassVar[list[str]] = []
43
44
  _storage_dir: ClassVar[str | None] = None
44
45
 
45
46
  def __init_subclass__(cls, **kwargs):
46
47
  super().__init_subclass__(**kwargs)
47
- if cls not in BaseEnvConfig._registry:
48
- BaseEnvConfig._registry.append(cls)
48
+ if cls in BaseEnvConfig._registry:
49
+ return
50
+ registry_key = cls.get_registry_key()
51
+ existing = BaseEnvConfig._registry_keys.get(registry_key)
52
+ if existing is not None:
53
+ setattr(cls, "_duplicate_of", existing)
54
+ return
55
+ BaseEnvConfig._registry.append(cls)
56
+ BaseEnvConfig._registry_keys[registry_key] = cls
57
+
58
+ @classmethod
59
+ def get_registry_key(cls) -> str:
60
+ """Return the logical registry key used for duplicate detection."""
61
+ return cls.get_storage_name().strip().lower()
49
62
 
50
63
  @classmethod
51
64
  def get_fields(cls) -> dict[str, EnvField]:
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+
6
+ from .fields import BaseEnvConfig, EnvField
7
+
8
+ _ENV_ASSIGN_RE = re.compile(r"(?:^|[^A-Za-z0-9_])(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
9
+ _SPACED_ENV_ASSIGN_RE = re.compile(r"(?:^|\s)(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
10
+
11
+
12
+ @dataclass
13
+ class PasteResult:
14
+ grouped: dict[type[BaseEnvConfig], dict[str, str]] = field(default_factory=dict)
15
+ unknown: list[str] = field(default_factory=list)
16
+
17
+ @property
18
+ def recognized_count(self) -> int:
19
+ return sum(len(values) for values in self.grouped.values())
20
+
21
+
22
+ def unquote_pasted_value(raw_value: str) -> str:
23
+ value = raw_value.strip()
24
+ if not value:
25
+ return ""
26
+ quote = value[0]
27
+ if quote in ("'", '"'):
28
+ chars: list[str] = []
29
+ escaped = False
30
+ for char in value[1:]:
31
+ if escaped:
32
+ chars.append(char)
33
+ escaped = False
34
+ continue
35
+ if quote == '"' and char == "\\":
36
+ escaped = True
37
+ continue
38
+ if char == quote:
39
+ return "".join(chars)
40
+ chars.append(char)
41
+ return "".join(chars)
42
+ if " #" in value:
43
+ value = value.split(" #", 1)[0].rstrip()
44
+ return value
45
+
46
+
47
+ def _comment_index(value: str) -> int | None:
48
+ index = value.find(" #")
49
+ return index if index >= 0 else None
50
+
51
+
52
+ def _quoted_value_end(line: str, value_start: int, quote: str) -> int:
53
+ escaped = False
54
+ for index in range(value_start + 1, len(line)):
55
+ char = line[index]
56
+ if escaped:
57
+ escaped = False
58
+ continue
59
+ if quote == '"' and char == "\\":
60
+ escaped = True
61
+ continue
62
+ if char == quote:
63
+ return index + 1
64
+ return len(line)
65
+
66
+
67
+ def _next_assignment_match(line: str, start: int):
68
+ return _SPACED_ENV_ASSIGN_RE.search(line, start)
69
+
70
+
71
+ def _parse_value_and_next_position(line: str, value_start: int) -> tuple[str, int]:
72
+ stripped_start = value_start
73
+ while stripped_start < len(line) and line[stripped_start].isspace():
74
+ stripped_start += 1
75
+
76
+ if stripped_start >= len(line):
77
+ return "", len(line)
78
+
79
+ quote = line[stripped_start]
80
+ if quote in ("'", '"'):
81
+ value_end = _quoted_value_end(line, stripped_start, quote)
82
+ next_match = _next_assignment_match(line, value_end)
83
+ if next_match is None:
84
+ next_pos = len(line)
85
+ else:
86
+ between = line[value_end:next_match.start()]
87
+ next_pos = len(line) if _comment_index(between) is not None else next_match.start()
88
+ return unquote_pasted_value(line[value_start:value_end]), next_pos
89
+
90
+ next_match = _next_assignment_match(line, value_start)
91
+ if next_match is None:
92
+ return unquote_pasted_value(line[value_start:]), len(line)
93
+
94
+ segment = line[value_start:next_match.start()]
95
+ comment = _comment_index(segment)
96
+ if comment is not None:
97
+ return unquote_pasted_value(segment[:comment]), len(line)
98
+ return unquote_pasted_value(segment), next_match.start()
99
+
100
+
101
+ def iter_pasted_assignments(line: str):
102
+ position = 0
103
+ while True:
104
+ match = _ENV_ASSIGN_RE.search(line, position)
105
+ if match is None:
106
+ break
107
+ key = match.group(1)
108
+ value, position = _parse_value_and_next_position(line, match.end())
109
+ yield key, value
110
+ if position >= len(line):
111
+ break
112
+
113
+
114
+ def extract_pasted_assignment(line: str) -> tuple[str, str] | None:
115
+ return next(iter_pasted_assignments(line), None)
116
+
117
+
118
+ def parse_pasted_env_text(text: str) -> PasteResult:
119
+ result = PasteResult()
120
+ for line in text.splitlines():
121
+ for key, value in iter_pasted_assignments(line):
122
+ match = BaseEnvConfig.find_field(key)
123
+ if match is None:
124
+ result.unknown.append(key)
125
+ continue
126
+ config_cls, field = match
127
+ result.grouped.setdefault(config_cls, {})[field.env_key] = value
128
+ return result
129
+
130
+
131
+ def iter_fields_for_values(config_cls: type[BaseEnvConfig], values: dict[str, str]):
132
+ fields_by_key: dict[str, EnvField] = {field.env_key: field for field in config_cls.get_fields().values()}
133
+ for key, value in values.items():
134
+ field = fields_by_key.get(key)
135
+ if field is not None:
136
+ yield field, value
@@ -66,14 +66,39 @@ class EnvStore:
66
66
  target.unlink()
67
67
  return target
68
68
 
69
+ def _render_explicit_env_file(
70
+ self,
71
+ config_cls: type[BaseEnvConfig],
72
+ values: dict[str, Any],
73
+ ) -> str:
74
+ lines = [f"# Description: Env file for {config_cls._title}.", ""]
75
+ fields_by_key = {field.env_key: field for field in config_cls.get_fields().values()}
76
+ written: set[str] = set()
77
+ for field in config_cls.get_fields().values():
78
+ if field.env_key not in values:
79
+ continue
80
+ if field.desc:
81
+ lines.append(f"# {field.desc}")
82
+ lines.append(f"{field.env_key}='{values[field.env_key]}'")
83
+ lines.append("")
84
+ written.add(field.env_key)
85
+ for key, value in values.items():
86
+ if key in written or key in fields_by_key:
87
+ continue
88
+ lines.append(f"{key}='{value}'")
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
69
92
  def _save(
70
93
  self,
71
94
  config_cls: type[BaseEnvConfig],
72
95
  target_path: Path,
73
96
  values: dict[str, Any] | None = None,
74
97
  ) -> Path:
75
- if values is not None:
76
- config_cls.load_from_sources(env_values=values)
77
98
  target_path.parent.mkdir(parents=True, exist_ok=True)
78
- target_path.write_text(config_cls.render_env_file(), encoding="utf-8")
99
+ if values is None:
100
+ content = config_cls.render_env_file()
101
+ else:
102
+ content = self._render_explicit_env_file(config_cls, values)
103
+ target_path.write_text(content, encoding="utf-8")
79
104
  return target_path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatenv
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: ChatArch typed environment profile manager
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -10,7 +10,7 @@ Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: chatstyle>=0.1.0
13
+ Requires-Dist: chatstyle<0.2.0,>=0.1.0
14
14
  Requires-Dist: click>=8.0
15
15
  Requires-Dist: python-dotenv>=1.0
16
16
  Provides-Extra: dev
@@ -48,7 +48,7 @@ ChatArch typed env/profile runtime.
48
48
 
49
49
  </div>
50
50
 
51
- ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它只提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;具体变量由 ChatTool、ChatDNS 等项目自己定义并注册。
51
+ ChatEnv 是 ChatArch / chatxxx 系列项目共用的 typed env/profile 底层包。它提供字段描述、配置基类、registry、路径、profile 文件读写、mask 和 paste 解析等通用能力;同时内置少量跨工具共享 schema(当前为 OpenAI / Feishu)。工具私有变量仍由 ChatTool、ChatDNS 等项目自己定义并注册。
52
52
 
53
53
  当前设计保持减法:只使用一个根变量 `CHATARCH_HOME`,只管理 env/profile 文件,不额外创建 config/cache/data/state。
54
54
 
@@ -115,12 +115,13 @@ export CHATARCH_AUTO_PROMPT=false
115
115
 
116
116
  ## Paste
117
117
 
118
- `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key
118
+ `paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
119
119
 
120
120
  ```bash
121
121
  chatenv paste
122
122
  chatenv paste --stdin --profile work
123
123
  chatenv paste --value "EXAMPLE_API_KEY='sk-xxx'" --yes
124
+ chatenv paste --value "EXAMPLE_MODEL='gpt example' EXAMPLE_API_KEY='sk-xxx'" --yes
124
125
  ```
125
126
 
126
127
  写入前会输出识别概要:识别到哪些类型、哪些 key、未知 key 被忽略。
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/chatenv/__init__.py
5
5
  src/chatenv/cli.py
6
+ src/chatenv/configs.py
6
7
  src/chatenv/discovery.py
7
8
  src/chatenv/fields.py
8
9
  src/chatenv/paste.py
@@ -1,4 +1,4 @@
1
- chatstyle>=0.1.0
1
+ chatstyle<0.2.0,>=0.1.0
2
2
  click>=8.0
3
3
  python-dotenv>=1.0
4
4
 
@@ -1,8 +1,11 @@
1
+ from pathlib import Path
1
2
  from types import SimpleNamespace
2
3
 
3
4
  from click.testing import CliRunner
5
+ import chatenv.discovery as discovery_module
4
6
  import chatenv.cli as cli_module
5
7
  from chatenv.cli import cli
8
+ from chatenv.configs import FeishuConfig, OpenAIConfig
6
9
  from chatenv.fields import BaseEnvConfig, EnvField
7
10
  from chatenv.paste import parse_pasted_env_text
8
11
  from chatenv.paths import get_paths
@@ -18,6 +21,129 @@ class UnitConfig(BaseEnvConfig):
18
21
  UNIT_VALUE = EnvField("UNIT_VALUE", default="default")
19
22
 
20
23
 
24
+ class RoundTripConfig(BaseEnvConfig):
25
+ _title = "RoundTrip Configuration"
26
+ _aliases = ["roundtrip"]
27
+ _storage_dir = "RoundTrip"
28
+
29
+ ROUNDTRIP_VALUE = EnvField("ROUNDTRIP_VALUE")
30
+ ROUNDTRIP_URL = EnvField("ROUNDTRIP_URL")
31
+
32
+
33
+ def test_builtin_shared_openai_and_feishu_configs_are_registered():
34
+ assert BaseEnvConfig.get_config_by_alias("openai") is OpenAIConfig
35
+ assert BaseEnvConfig.get_config_by_alias("oai") is OpenAIConfig
36
+ assert BaseEnvConfig.get_config_by_alias("feishu") is FeishuConfig
37
+ assert BaseEnvConfig.get_config_by_alias("lark") is FeishuConfig
38
+
39
+ assert OpenAIConfig.get_storage_name() == "OpenAI"
40
+ assert FeishuConfig.get_storage_name() == "Feishu"
41
+ assert "OPENAI_API_KEY" in [field.env_key for field in OpenAIConfig.get_fields().values()]
42
+ assert "FEISHU_APP_SECRET" in [field.env_key for field in FeishuConfig.get_fields().values()]
43
+
44
+
45
+ def test_duplicate_logical_config_registration_is_skipped():
46
+ registry_before = list(BaseEnvConfig._registry)
47
+
48
+ class DuplicateOpenAIConfig(BaseEnvConfig):
49
+ _title = "Duplicate OpenAI Configuration"
50
+ _aliases = ["duplicate-openai"]
51
+ _storage_dir = "OpenAI"
52
+
53
+ DUPLICATE_OPENAI_KEY = EnvField("DUPLICATE_OPENAI_KEY")
54
+
55
+ assert DuplicateOpenAIConfig not in BaseEnvConfig._registry
56
+ assert getattr(DuplicateOpenAIConfig, "_duplicate_of") is OpenAIConfig
57
+ assert BaseEnvConfig.get_config_by_alias("openai") is OpenAIConfig
58
+ assert BaseEnvConfig.find_field("DUPLICATE_OPENAI_KEY") is None
59
+ assert BaseEnvConfig._registry == registry_before
60
+
61
+
62
+ def test_duplicate_provider_config_does_not_pollute_registry(monkeypatch):
63
+ registry_before = list(BaseEnvConfig._registry)
64
+ provider_configs_before = discovery_module.get_provider_configs()
65
+
66
+ class FakeEntryPoint:
67
+ name = "legacy-chattool"
68
+ value = "legacy_chattool.config"
69
+
70
+ def load(self):
71
+ class LegacyFeishuConfig(BaseEnvConfig):
72
+ _title = "Legacy Feishu Configuration"
73
+ _aliases = ["legacy-feishu"]
74
+ _storage_dir = "Feishu"
75
+
76
+ LEGACY_FEISHU_ONLY = EnvField("LEGACY_FEISHU_ONLY")
77
+
78
+ return LegacyFeishuConfig
79
+
80
+ monkeypatch.setattr(discovery_module, "_iter_entry_points", lambda: [FakeEntryPoint()])
81
+
82
+ results = discovery_module.load_config_providers(force=True)
83
+
84
+ assert len(results) == 1
85
+ assert results[0].loaded is True
86
+ assert results[0].configs == ()
87
+ assert BaseEnvConfig._registry == registry_before
88
+ assert BaseEnvConfig.get_config_by_alias("feishu") is FeishuConfig
89
+ assert BaseEnvConfig.find_field("LEGACY_FEISHU_ONLY") is None
90
+
91
+ discovery_module._provider_configs.clear()
92
+ discovery_module._provider_configs.update(provider_configs_before)
93
+
94
+
95
+ def test_chatarch_internal_dependencies_have_upper_bounds():
96
+ pyproject_text = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(
97
+ encoding="utf-8"
98
+ )
99
+
100
+ assert '"chatstyle>=0.1.0,<0.2.0"' in pyproject_text
101
+ assert '"chatstyle>=0.1.0"' not in pyproject_text
102
+
103
+
104
+ def test_cli_set_only_writes_the_requested_key(monkeypatch, tmp_path):
105
+ monkeypatch.setenv("OPENAI_API_KEY", "system-secret")
106
+ runner = CliRunner()
107
+ home = tmp_path / "arch"
108
+
109
+ result = runner.invoke(
110
+ cli,
111
+ ["--home", str(home), "set", "OPENAI_API_BASE=https://example.invalid/v1"],
112
+ )
113
+
114
+ assert result.exit_code == 0, result.output
115
+ env_text = (home / "envs" / "OpenAI" / ".env").read_text(encoding="utf-8")
116
+ assert "OPENAI_API_BASE='https://example.invalid/v1'" in env_text
117
+ assert "OPENAI_API_KEY" not in env_text
118
+
119
+
120
+ def test_cli_paste_active_merges_file_values_without_system_env(monkeypatch, tmp_path):
121
+ monkeypatch.setenv("OPENAI_API_KEY", "system-secret")
122
+ runner = CliRunner()
123
+ home = tmp_path / "arch"
124
+ openai_active = home / "envs" / "OpenAI" / ".env"
125
+ openai_active.parent.mkdir(parents=True)
126
+ openai_active.write_text("OPENAI_API_BASE='https://old.example/v1'\n", encoding="utf-8")
127
+
128
+ result = runner.invoke(
129
+ cli,
130
+ [
131
+ "--home",
132
+ str(home),
133
+ "paste",
134
+ "--value",
135
+ "OPENAI_API_MODEL='gpt-test'",
136
+ "--yes",
137
+ ],
138
+ )
139
+
140
+ assert result.exit_code == 0, result.output
141
+ env_text = openai_active.read_text(encoding="utf-8")
142
+ assert "OPENAI_API_BASE='https://old.example/v1'" in env_text
143
+ assert "OPENAI_API_MODEL='gpt-test'" in env_text
144
+ assert "OPENAI_API_KEY" not in env_text
145
+
146
+
21
147
  def test_paths_only_use_chatarch_home(monkeypatch, tmp_path):
22
148
  monkeypatch.setenv("CHATARCH_HOME", str(tmp_path / "arch"))
23
149
  paths = get_paths()
@@ -65,6 +191,66 @@ def test_paste_parser_extracts_from_loose_terminal_text():
65
191
  assert result.unknown == ["UNKNOWN_KEY"]
66
192
 
67
193
 
194
+ def test_paste_parser_extracts_multiple_assignments_on_one_spaced_line():
195
+ text = "UNIT_VALUE='hello world' UNIT_KEY='sk with spaces' UNKNOWN_KEY=nope"
196
+
197
+ result = parse_pasted_env_text(text)
198
+
199
+ assert result.grouped[UnitConfig]["UNIT_VALUE"] == "hello world"
200
+ assert result.grouped[UnitConfig]["UNIT_KEY"] == "sk with spaces"
201
+ assert result.unknown == ["UNKNOWN_KEY"]
202
+
203
+
204
+ def test_paste_parser_keeps_url_query_params_before_spaced_assignment():
205
+ text = "UNIT_VALUE=https://api.example.test/v1?x=1 UNIT_KEY='secret'"
206
+
207
+ result = parse_pasted_env_text(text)
208
+
209
+ assert result.grouped[UnitConfig]["UNIT_VALUE"] == "https://api.example.test/v1?x=1"
210
+ assert result.grouped[UnitConfig]["UNIT_KEY"] == "secret"
211
+ assert result.unknown == []
212
+
213
+
214
+ def test_paste_parser_keeps_empty_value_before_spaced_assignment():
215
+ text = "UNIT_VALUE= UNIT_KEY='secret'"
216
+
217
+ result = parse_pasted_env_text(text)
218
+
219
+ assert result.grouped[UnitConfig]["UNIT_VALUE"] == ""
220
+ assert result.grouped[UnitConfig]["UNIT_KEY"] == "secret"
221
+
222
+
223
+ def test_paste_parser_stops_at_inline_comment_before_later_assignment():
224
+ text = "UNIT_VALUE='hello world' # comment UNIT_KEY='secret'"
225
+
226
+ result = parse_pasted_env_text(text)
227
+
228
+ assert result.grouped[UnitConfig]["UNIT_VALUE"] == "hello world"
229
+ assert "UNIT_KEY" not in result.grouped[UnitConfig]
230
+
231
+
232
+ def test_paste_parser_accepts_export_prefix_on_same_line_assignments():
233
+ text = "export UNIT_VALUE='hello world' UNIT_KEY='secret'"
234
+
235
+ result = parse_pasted_env_text(text)
236
+
237
+ assert result.grouped[UnitConfig]["UNIT_VALUE"] == "hello world"
238
+ assert result.grouped[UnitConfig]["UNIT_KEY"] == "secret"
239
+
240
+
241
+ def test_cli_paste_accepts_space_separated_assignments(tmp_path):
242
+ runner = CliRunner()
243
+ home = tmp_path / "arch"
244
+ value = "UNIT_VALUE='from spaced paste' UNIT_KEY='sk with spaces'"
245
+
246
+ result = runner.invoke(cli, ["--home", str(home), "paste", "--value", value, "--yes"])
247
+
248
+ assert result.exit_code == 0, result.output
249
+ env_text = (home / "envs" / "Unit" / ".env").read_text(encoding="utf-8")
250
+ assert "UNIT_VALUE='from spaced paste'" in env_text
251
+ assert "UNIT_KEY='sk with spaces'" in env_text
252
+
253
+
68
254
  def test_cli_paste_active_and_cat(tmp_path):
69
255
  runner = CliRunner()
70
256
  home = tmp_path / "arch"
@@ -82,6 +268,55 @@ def test_cli_paste_active_and_cat(tmp_path):
82
268
  assert "abcdefghijklmnopqrstuvwxyz" not in cat.output
83
269
 
84
270
 
271
+ def test_cli_cat_output_with_blank_lines_can_be_pasted_to_profile(tmp_path):
272
+ runner = CliRunner()
273
+ source_home = tmp_path / "source"
274
+ target_home = tmp_path / "target"
275
+ value = "\n".join(
276
+ [
277
+ "UNIT_VALUE='hello world with spaces'",
278
+ "UNIT_KEY='unit dummy value with spaces'",
279
+ "ROUNDTRIP_VALUE='roundtrip value with spaces'",
280
+ "ROUNDTRIP_URL=https://api.example.test/v1?x=1",
281
+ ]
282
+ )
283
+ seed = runner.invoke(
284
+ cli,
285
+ ["--home", str(source_home), "paste", "--value", value, "--yes"],
286
+ )
287
+ assert seed.exit_code == 0, seed.output
288
+
289
+ cat = runner.invoke(cli, ["--home", str(source_home), "cat", "--no-mask"])
290
+ assert cat.exit_code == 0, cat.output
291
+ assert "\n\n# RoundTrip\n" in cat.output
292
+
293
+ imported = runner.invoke(
294
+ cli,
295
+ [
296
+ "--home",
297
+ str(target_home),
298
+ "paste",
299
+ "--value",
300
+ cat.output,
301
+ "--profile",
302
+ "copy",
303
+ "--yes",
304
+ ],
305
+ )
306
+ assert imported.exit_code == 0, imported.output
307
+
308
+ unit_copy = (target_home / "envs" / "Unit" / "copy.env").read_text(
309
+ encoding="utf-8"
310
+ )
311
+ roundtrip_copy = (target_home / "envs" / "RoundTrip" / "copy.env").read_text(
312
+ encoding="utf-8"
313
+ )
314
+ assert "UNIT_VALUE='hello world with spaces'" in unit_copy
315
+ assert "UNIT_KEY='unit dummy value with spaces'" in unit_copy
316
+ assert "ROUNDTRIP_VALUE='roundtrip value with spaces'" in roundtrip_copy
317
+ assert "ROUNDTRIP_URL='https://api.example.test/v1?x=1'" in roundtrip_copy
318
+
319
+
85
320
  def test_cli_delete_profile_requires_confirmation(tmp_path):
86
321
  runner = CliRunner()
87
322
  home = tmp_path / "arch"
@@ -2,4 +2,4 @@ from chatenv import __version__
2
2
 
3
3
 
4
4
  def test_version_present():
5
- assert __version__ == "0.1.2"
5
+ assert __version__ == "0.2.0"
@@ -1,75 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from dataclasses import dataclass, field
5
-
6
- from .fields import BaseEnvConfig, EnvField
7
-
8
- _ENV_ASSIGN_RE = re.compile(r"(?:^|[^A-Za-z0-9_])(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
9
-
10
-
11
- @dataclass
12
- class PasteResult:
13
- grouped: dict[type[BaseEnvConfig], dict[str, str]] = field(default_factory=dict)
14
- unknown: list[str] = field(default_factory=list)
15
-
16
- @property
17
- def recognized_count(self) -> int:
18
- return sum(len(values) for values in self.grouped.values())
19
-
20
-
21
- def unquote_pasted_value(raw_value: str) -> str:
22
- value = raw_value.strip()
23
- if not value:
24
- return ""
25
- quote = value[0]
26
- if quote in ("'", '"'):
27
- chars: list[str] = []
28
- escaped = False
29
- for char in value[1:]:
30
- if escaped:
31
- chars.append(char)
32
- escaped = False
33
- continue
34
- if quote == '"' and char == "\\":
35
- escaped = True
36
- continue
37
- if char == quote:
38
- return "".join(chars)
39
- chars.append(char)
40
- return "".join(chars)
41
- if " #" in value:
42
- value = value.split(" #", 1)[0].rstrip()
43
- return value
44
-
45
-
46
- def extract_pasted_assignment(line: str) -> tuple[str, str] | None:
47
- match = _ENV_ASSIGN_RE.search(line)
48
- if match is None:
49
- return None
50
- key = match.group(1)
51
- return key, unquote_pasted_value(line[match.end():])
52
-
53
-
54
- def parse_pasted_env_text(text: str) -> PasteResult:
55
- result = PasteResult()
56
- for line in text.splitlines():
57
- parsed = extract_pasted_assignment(line)
58
- if parsed is None:
59
- continue
60
- key, value = parsed
61
- match = BaseEnvConfig.find_field(key)
62
- if match is None:
63
- result.unknown.append(key)
64
- continue
65
- config_cls, field = match
66
- result.grouped.setdefault(config_cls, {})[field.env_key] = value
67
- return result
68
-
69
-
70
- def iter_fields_for_values(config_cls: type[BaseEnvConfig], values: dict[str, str]):
71
- fields_by_key: dict[str, EnvField] = {field.env_key: field for field in config_cls.get_fields().values()}
72
- for key, value in values.items():
73
- field = fields_by_key.get(key)
74
- if field is not None:
75
- yield field, value
File without changes
File without changes
File without changes
File without changes
File without changes