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.
- {chatenv-0.1.2/src/chatenv.egg-info → chatenv-0.2.0}/PKG-INFO +5 -4
- {chatenv-0.1.2 → chatenv-0.2.0}/README.md +3 -2
- {chatenv-0.1.2 → chatenv-0.2.0}/pyproject.toml +1 -1
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/__init__.py +4 -1
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/cli.py +10 -7
- chatenv-0.2.0/src/chatenv/configs.py +103 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/fields.py +15 -2
- chatenv-0.2.0/src/chatenv/paste.py +136 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/store.py +28 -3
- {chatenv-0.1.2 → chatenv-0.2.0/src/chatenv.egg-info}/PKG-INFO +5 -4
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/SOURCES.txt +1 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/requires.txt +1 -1
- {chatenv-0.1.2 → chatenv-0.2.0}/tests/test_chatenv_core.py +235 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/tests/test_version.py +1 -1
- chatenv-0.1.2/src/chatenv/paste.py +0 -75
- {chatenv-0.1.2 → chatenv-0.2.0}/LICENSE +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/setup.cfg +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/discovery.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/paths.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/presets/__init__.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/registry.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/source_chain.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv/utils.py +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/dependency_links.txt +0 -0
- {chatenv-0.1.2 → chatenv-0.2.0}/src/chatenv.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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 被忽略。
|
|
@@ -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.
|
|
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,
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 被忽略。
|
|
@@ -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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|