diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml
new file mode 100644
index 0000000000..a6af93a26f
--- /dev/null
+++ b/.github/workflows/binary-release.yml
@@ -0,0 +1,150 @@
+name: Build and Release with PyInstaller
+
+on:
+ push:
+ tags:
+ - "*"
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ os: [ ubuntu-latest, macos-latest, windows-latest ]
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ # 二进制包仅依赖精简版 SDK(tencentcloud-sdk-python-common),不安装完整 SDK。
+ # 使用 --no-deps 安装 tccli 本体,避免 setup.py 声明的 tencentcloud-sdk-python(完整 SDK)被拉入,
+ # 从而显著减小二进制体积。存量 pip 安装不走此流程,行为不受影响。
+ - name: Install dependencies (lightweight common SDK for binary)
+ run: |
+ python -m pip install --upgrade pip
+ pip install pyinstaller
+ pip install tencentcloud-sdk-python-common jmespath six cos-python-sdk-v5
+ pip install -e . --no-deps
+
+ # 裁剪各服务的 *_client.py。删除后,运行时 command.py 中
+ # Services.action_caller() 会抛 ImportError,从而自动降级到基于 CommonClient
+ # 的 GenericActionCaller。api.json / examples.json 等元数据保留,供 GenericActionCaller 使用。
+ # 使用 Python 实现,保证在 Linux/macOS/Windows 三平台通用。
+ - name: Strip per-service *_client.py (binary uses CommonClient)
+ run: python -c "import glob, os; files = glob.glob('tccli/services/**/*_client.py', recursive=True); [os.remove(f) for f in files]; print('removed %d client files' % len(files))"
+
+ # 使用目录模式(-D)打包,避免单文件模式每次运行都解压的启动延迟。
+ - name: Run PyInstaller
+ run: pyinstaller tccli/main.py -y -D --collect-all tccli --exclude-module tccli.examples --name tccli
+
+ # --collect-all 可能从已安装包中收集到 *_client.py,打包后二次裁剪确保干净
+ - name: Strip *_client.py from dist (post-build cleanup)
+ run: python -c "import glob, os; files = glob.glob('dist/tccli/_internal/tccli/services/**/*_client.py', recursive=True); [os.remove(f) for f in files]; print('post-build removed %d client files' % len(files))"
+
+ # macOS 会校验主程序与其加载的 Python.framework / dylib 签名是否兼容。
+ # 按“内部 Mach-O -> Python.framework -> 主程序”的顺序统一做 ad-hoc 签名;
+ # 主程序最后签名,避免后续修改内部文件导致签名失效。
+ - name: Re-sign macOS onedir bundle
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ set -euo pipefail
+ DIST_DIR="dist/tccli"
+
+ # 先签 _internal 中的动态库、扩展模块和 Framework 可执行文件。
+ while IFS= read -r -d '' file_path; do
+ if file -b "$file_path" | grep -q 'Mach-O'; then
+ echo "Signing Mach-O: $file_path"
+ codesign --remove-signature "$file_path" 2>/dev/null || true
+ codesign --force --sign - --timestamp=none "$file_path"
+ fi
+ done < <(find "$DIST_DIR/_internal" -type f -print0)
+
+ # 再签 Framework 容器,使其嵌套签名与内部 Python 可执行文件一致。
+ if [[ -d "$DIST_DIR/_internal/Python.framework" ]]; then
+ codesign --force --deep --sign - --timestamp=none \
+ "$DIST_DIR/_internal/Python.framework"
+ fi
+
+ # 下载后的 ad-hoc 程序没有 Apple Team ID;启用 hardened runtime 时,dyld 默认会
+ # 拒绝加载无相同 Team ID 的 Python.framework。显式关闭主程序的 Library Validation,
+ # 同时保留其它 hardened-runtime 保护。
+ ENTITLEMENTS="$RUNNER_TEMP/tccli-entitlements.plist"
+ cat > "$ENTITLEMENTS" <<'PLIST'
+
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
+ PLIST
+
+ # 最后签主程序,并嵌入允许加载随包 Python.framework 的 entitlement。
+ codesign --remove-signature "$DIST_DIR/tccli" 2>/dev/null || true
+ codesign --force --sign - --timestamp=none --options runtime \
+ --entitlements "$ENTITLEMENTS" "$DIST_DIR/tccli"
+
+ # 在上传 Release 前验证签名、entitlement,并实际启动一次;失败则终止工作流。
+ codesign --verify --strict --verbose=4 "$DIST_DIR/tccli"
+ if [[ -f "$DIST_DIR/_internal/Python.framework/Versions/3.12/Python" ]]; then
+ codesign --verify --strict --verbose=4 \
+ "$DIST_DIR/_internal/Python.framework/Versions/3.12/Python"
+ fi
+ codesign -d --entitlements :- "$DIST_DIR/tccli" 2>&1 | \
+ grep -q 'com.apple.security.cs.disable-library-validation'
+ "$DIST_DIR/tccli" --version
+
+ - name: Package binary (Linux)
+ if: runner.os == 'Linux'
+ working-directory: dist
+ run: tar -czf tccli-linux.tar.gz tccli/
+
+ - name: Package binary (macOS)
+ if: runner.os == 'macOS'
+ working-directory: dist
+ run: tar -czf tccli-macos.tar.gz tccli/
+
+ - name: Package binary (Windows)
+ if: runner.os == 'Windows'
+ run: Compress-Archive -Path "dist\tccli" -DestinationPath "dist\tccli-windows.zip"
+ shell: powershell
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: "tccli-${{ runner.os }}"
+ path: |
+ dist/*.tar.gz
+ dist/*.zip
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Create GitHub Release
+ id: create_release
+ uses: softprops/action-gh-release@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: Release ${{ github.ref_name }}
+ draft: false
+ prerelease: false
+ files: |
+ artifacts/**/*.tar.gz
+ artifacts/**/*.zip
diff --git a/tccli/action_caller.py b/tccli/action_caller.py
new file mode 100644
index 0000000000..ef7aa8f60d
--- /dev/null
+++ b/tccli/action_caller.py
@@ -0,0 +1,253 @@
+# -*- coding:utf-8 -*-
+import json
+import os
+import os.path as path
+import time
+
+import six
+from jmespath import search
+from tencentcloud.common import credential
+from tencentcloud.common.common_client import CommonClient
+from tencentcloud.common.profile.client_profile import ClientProfile
+from tencentcloud.common.profile.http_profile import HttpProfile
+
+import tccli.format_output as format_output
+import tccli.options_define as options_define
+from tccli import __version__
+from tccli.exceptions import ConfigurationError, ClientError
+from tccli.loaders import Loader, BASE_TYPE
+from tccli.utils import Utils
+
+
+class GenericActionCaller(object):
+ def __init__(self, module, action):
+ self._module = module
+ self._action = action
+ self._avail_vers = []
+
+ def __call__(self, args, parsed_globals):
+ self._call_json(args, parsed_globals)
+
+ def available_versions(self):
+ if not self._avail_vers:
+ svc_path = os.path.join(path.dirname(path.abspath(__file__)), 'services', self._module)
+ try:
+ dirs = os.listdir(svc_path)
+ except OSError:
+ raise ConfigurationError("service '%s' not found" % self._module)
+ self._avail_vers = [d for d in dirs if d[0] == "v" and os.path.isdir(path.join(svc_path, d))]
+
+ return self._avail_vers
+
+ @staticmethod
+ def convert_version_str(ver):
+ return ver[1:5] + "-" + ver[5:7] + "-" + ver[7:9]
+
+ def _call_json(self, args, parsed_globals):
+ g_param = self.parse_global_arg(parsed_globals)
+
+ if g_param[options_define.UseCVMRole.replace('-', '_')]:
+ cred = credential.CVMRoleCredential()
+ elif g_param[options_define.RoleArn.replace('-', '_')] and g_param[
+ options_define.RoleSessionName.replace('-', '_')]:
+ cred = credential.STSAssumeRoleCredential(
+ g_param[options_define.SecretId], g_param[options_define.SecretKey],
+ g_param[options_define.RoleArn.replace('-', '_')],
+ g_param[options_define.RoleSessionName.replace('-', '_')], endpoint=g_param["sts_cred_endpoint"]
+ )
+ elif os.getenv(options_define.ENV_TKE_REGION) and os.getenv(options_define.ENV_TKE_PROVIDER_ID) and os.getenv(
+ options_define.ENV_TKE_WEB_IDENTITY_TOKEN_FILE) and os.getenv(options_define.ENV_TKE_ROLE_ARN):
+ cred = credential.DefaultTkeOIDCRoleArnProvider().get_credentials()
+ else:
+ cred = credential.Credential(
+ g_param[options_define.SecretId], g_param[options_define.SecretKey], g_param[options_define.Token]
+ )
+ http_profile = HttpProfile(
+ reqTimeout=60 if g_param[options_define.Timeout] is None else int(g_param[options_define.Timeout]),
+ reqMethod="POST",
+ endpoint=g_param[options_define.Endpoint],
+ proxy=g_param[options_define.HttpsProxy.replace('-', '_')]
+ )
+ cpf = ClientProfile(httpProfile=http_profile, signMethod="HmacSHA256")
+ if g_param[options_define.Language]:
+ cpf.language = g_param[options_define.Language]
+ version = self.convert_version_str(g_param[options_define.Version])
+ region = g_param[options_define.Region]
+ client = CommonClient(self._module, version, cred, region, cpf)
+ client._sdkVersion += ("_CLI_" + __version__)
+
+ start_time = time.time()
+ while True:
+ raw = client.call_json(self._action, args)
+ json_obj = raw.get("Response", raw) if isinstance(raw, dict) else raw
+ json_obj = self._filter_response(json_obj, g_param[options_define.Version])
+
+ if not g_param[options_define.Waiter] or search(g_param['OptionsDefine.WaiterInfo']['expr'], json_obj) == \
+ g_param['OptionsDefine.WaiterInfo']['to']:
+ break
+
+ cur_time = time.time()
+ if cur_time - start_time >= g_param['OptionsDefine.WaiterInfo']['timeout']:
+ raise ClientError('Request timeout, wait `%s` to `%s` timeout, last request is %s' %
+ (g_param['OptionsDefine.WaiterInfo']['expr'],
+ g_param['OptionsDefine.WaiterInfo']['to'],
+ search(g_param['OptionsDefine.WaiterInfo']['expr'], json_obj)))
+ else:
+ print('Inquiry result is %s.' % search(g_param['OptionsDefine.WaiterInfo']['expr'], json_obj))
+ time.sleep(g_param['OptionsDefine.WaiterInfo']['interval'])
+
+ format_output.output("action", json_obj, g_param[options_define.Output], g_param[options_define.Filter])
+
+ def parse_global_arg(self, parsed_globals):
+ g_param = parsed_globals
+ cvm_role_flag = True
+ for param in parsed_globals.keys():
+ if param in [options_define.SecretKey, options_define.SecretId, options_define.RoleArn,
+ options_define.RoleSessionName]:
+ if parsed_globals[param] is not None:
+ cvm_role_flag = False
+ break
+ is_exist_profile = True
+ if not parsed_globals["profile"]:
+ is_exist_profile = False
+ g_param["profile"] = os.environ.get("TCCLI_PROFILE", "default")
+
+ configure_path = os.path.join(os.path.expanduser("~"), ".tccli")
+ is_conf_exist, conf_path = Utils.file_existed(configure_path, g_param["profile"] + ".configure")
+ is_cred_exist, cred_path = Utils.file_existed(configure_path, g_param["profile"] + ".credential")
+
+ conf = {}
+ cred = {}
+
+ if is_conf_exist:
+ conf = Utils.load_json_msg(conf_path)
+ if is_cred_exist:
+ cred = Utils.load_json_msg(cred_path)
+
+ if not (isinstance(conf, dict) and isinstance(cred, dict)):
+ raise ConfigurationError(
+ "file: %s or %s is not json format"
+ % (g_param["profile"] + ".configure", g_param["profile"] + ".credential"))
+
+ if options_define.Token not in cred:
+ cred[options_define.Token] = None
+
+ if not is_exist_profile:
+ if os.environ.get(options_define.ENV_SECRET_ID) and os.environ.get(options_define.ENV_SECRET_KEY):
+ cred[options_define.SecretId] = os.environ.get(options_define.ENV_SECRET_ID)
+ cred[options_define.SecretKey] = os.environ.get(options_define.ENV_SECRET_KEY)
+ cred[options_define.Token] = os.environ.get(options_define.ENV_TOKEN)
+ cvm_role_flag = False
+
+ if os.environ.get(options_define.ENV_REGION):
+ conf[options_define.SysParam][options_define.Region] = os.environ.get(options_define.ENV_REGION)
+
+ if os.environ.get(options_define.ENV_ROLE_ARN) and os.environ.get(options_define.ENV_ROLE_SESSION_NAME):
+ cred[options_define.RoleArn] = os.environ.get(options_define.ENV_ROLE_ARN)
+ cred[options_define.RoleSessionName] = os.environ.get(options_define.ENV_ROLE_SESSION_NAME)
+ cvm_role_flag = False
+
+ if cvm_role_flag:
+ if "type" in cred and cred["type"] == "cvm-role":
+ g_param[options_define.UseCVMRole.replace('-', '_')] = True
+
+ for param in g_param.keys():
+ if g_param[param] is None:
+ if param in [options_define.SecretKey, options_define.SecretId, options_define.Token]:
+ if param in cred:
+ g_param[param] = cred[param]
+ elif not (g_param[options_define.UseCVMRole.replace('-', '_')]
+ or os.getenv(options_define.ENV_TKE_ROLE_ARN)):
+ raise ConfigurationError("%s is invalid" % param)
+ elif param in [options_define.Region, options_define.Output, options_define.Language]:
+ if param in conf[options_define.SysParam]:
+ g_param[param] = conf[options_define.SysParam][param]
+ elif param != options_define.Language:
+ raise ConfigurationError("%s is invalid" % param)
+ elif param.replace('_', '-') in [options_define.RoleArn, options_define.RoleSessionName]:
+ if param.replace('_', '-') in cred:
+ g_param[param] = cred[param.replace('_', '-')]
+
+ try:
+ if g_param[options_define.ServiceVersion]:
+ g_param[options_define.Version] = "v" + g_param[options_define.ServiceVersion].replace('-', '')
+ else:
+ version = conf[self._module][options_define.Version]
+ g_param[options_define.Version] = "v" + version.replace('-', '')
+
+ if g_param[options_define.Endpoint] is None:
+ g_param[options_define.Endpoint] = conf[self._module][options_define.Endpoint]
+ g_param["sts_cred_endpoint"] = conf.get("sts", {}).get("endpoint")
+ except Exception as err:
+ raise ConfigurationError("config file:%s error, %s" % (conf_path, str(err)))
+
+ if g_param[options_define.Version] not in self.available_versions():
+ raise Exception("available versions: %s" % " ".join(self.available_versions()))
+
+ if g_param[options_define.Waiter]:
+ try:
+ param = json.loads(g_param[options_define.Waiter])
+ except ValueError as e:
+ raise Exception('`--waiter` must be a valid JSON string: %s' % str(e))
+ if 'expr' not in param:
+ raise Exception('`expr` in `--waiter` must be defined')
+ if 'to' not in param:
+ raise Exception('`to` in `--waiter` must be defined')
+ if 'timeout' not in param:
+ if 'waiter' in conf and 'timeout' in conf['waiter']:
+ param['timeout'] = conf['waiter']['timeout']
+ else:
+ param['timeout'] = 180
+ if 'interval' not in param:
+ if 'waiter' in conf and 'interval' in conf['waiter']:
+ param['interval'] = conf['waiter']['interval']
+ else:
+ param['interval'] = 5
+ param['interval'] = min(param['interval'], param['timeout'])
+ g_param['OptionsDefine.WaiterInfo'] = param
+
+ if six.PY2:
+ for key, value in g_param.items():
+ if isinstance(value, six.text_type):
+ g_param[key] = value.encode('utf-8')
+ return g_param
+
+ def _filter_response(self, data, version):
+ """加载 api.json 中 ActionResponse 的 schema,对响应 dict 做白名单字段投影"""
+ try:
+ loader = Loader()
+ service_model = loader.get_service_model(self._module, self.convert_version_str(version))
+ objects = service_model["objects"]
+ resp_name = self._action + "Response"
+ if resp_name not in objects:
+ return data
+ return self._filter_by_schema(data, objects[resp_name]["members"], objects)
+ except Exception:
+ return data
+
+ def _filter_by_schema(self, data, members, objects):
+ """按 schema members 递归投影 dict,丢弃未定义字段,复杂类型递归处理"""
+ if not isinstance(data, dict):
+ return data
+ result = {}
+ for para in members:
+ name = para["name"]
+ if name not in data:
+ continue
+ value = data[name]
+ if para["type"] == "list":
+ if para["member"] not in BASE_TYPE:
+ if isinstance(value, list):
+ sub_members = objects[para["member"]]["members"]
+ result[name] = [self._filter_by_schema(item, sub_members, objects) for item in value]
+ else:
+ result[name] = value
+ else:
+ result[name] = value
+ else:
+ if para["member"] not in BASE_TYPE:
+ sub_members = objects[para["member"]]["members"]
+ result[name] = self._filter_by_schema(value, sub_members, objects)
+ else:
+ result[name] = value
+ return result
diff --git a/tccli/command.py b/tccli/command.py
index fe94f6d113..da3c640e0f 100644
--- a/tccli/command.py
+++ b/tccli/command.py
@@ -8,6 +8,7 @@
from collections import OrderedDict
from tccli import credentials
+from tccli.action_caller import GenericActionCaller
from tccli.utils import Utils
from tccli.argument import CLIArgument, CustomArgument, ListArgument, BooleanArgument
from tccli.exceptions import UnknownArgumentError
@@ -176,11 +177,21 @@ def _get_command_map(self):
def _build_command_map(self):
command_map = OrderedDict()
service_model = self._get_service_model()
+ # 优先使用各服务的 *_client.py(存量 pip 安装行为,依赖完整 SDK);
+ # 若 client.py 不可用(例如二进制包已裁剪掉 *_client.py),则降级到
+ # 基于 CommonClient 的 GenericActionCaller。存量 pip 安装 client.py 始终存在,行为保持不变。
+ try:
+ service_action_caller = Services.action_caller(self._service_name)()
+ except ImportError:
+ service_action_caller = None
for action in service_model["actions"]:
action_model = service_model["actions"][action]
action_caller = action_model.get("action_caller", None)
if not action_caller:
- action_caller = Services.action_caller(self._service_name)()[action]
+ if service_action_caller is not None:
+ action_caller = service_action_caller[action]
+ else:
+ action_caller = GenericActionCaller(self._service_name, action)
command_map[action] = ActionCommand(
service_name=self._service_name,
version=self._version,
diff --git a/tccli/plugins/test/add.py b/tccli/plugins/test/add.py
index d3475a6345..8516df5cb9 100644
--- a/tccli/plugins/test/add.py
+++ b/tccli/plugins/test/add.py
@@ -4,7 +4,7 @@
from tencentcloud.common import credential
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
-from tencentcloud.cvm.v20170312 import cvm_client, models
+from tencentcloud.common.common_client import CommonClient
def add_command(args, parsed_globals):
@@ -21,11 +21,10 @@ def add_command(args, parsed_globals):
# do api call with secret key
cred = credential.Credential(secret_id, secret_key, token)
- cli = cvm_client.CvmClient(cred, region)
+ cli = CommonClient("cvm", "2017-03-12", cred, region)
- req = models.DescribeInstancesRequest()
try:
- resp = cli.DescribeInstances(req)
- print(resp.to_json_string(indent=2))
+ resp = cli.call("DescribeInstances", {"Limit": 10})
+ print(json.dumps(resp))
except TencentCloudSDKException as e:
logging.exception(e)
diff --git a/tests/test_action_caller.py b/tests/test_action_caller.py
new file mode 100644
index 0000000000..30bfb98815
--- /dev/null
+++ b/tests/test_action_caller.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from tccli.action_caller import GenericActionCaller
+
+
+# ── convert_version_str ───────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("ver_input,expected", [
+ ("v20170312", "2017-03-12"),
+ ("v20230101", "2023-01-01"),
+])
+def test_convert_version_str(ver_input, expected):
+ assert GenericActionCaller.convert_version_str(ver_input) == expected
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000000..e180b63120
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,10 @@
+# -*- coding:utf8 -*-
+
+from utils import shell, recover_profile
+
+
+@recover_profile()
+def test_auth():
+ assert "在浏览器中转到以下链接, 并根据提示完成登录" in shell("tccli auth login --browser no")
+
+ assert "登出成功, 密钥凭证已被删除:" in shell("tccli auth logout")
diff --git a/tests/test_configure.py b/tests/test_configure.py
new file mode 100644
index 0000000000..5080c444a7
--- /dev/null
+++ b/tests/test_configure.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+import io
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from tccli.configure import ConfigureGetCommand, ConfigureSetCommand, ConfigureListCommand
+from tccli.exceptions import ConfigurationError, ParamError
+from utils import shell, recover_profile
+
+
+# ── ConfigureSetCommand:output / language 非法值回退 ─────────────────────────
+
+def _make_set_cmd():
+ return ConfigureSetCommand()
+
+
+@pytest.mark.parametrize("bad_output", ["xml", "yaml", ""])
+def test_configure_set_invalid_output_falls_back_to_json(tmp_path, monkeypatch, bad_output, capsys):
+ monkeypatch.setattr("tccli.configure.BasicConfigure.cli_path", str(tmp_path), raising=False)
+ cmd = _make_set_cmd()
+ monkeypatch.setattr(cmd, "cli_path", str(tmp_path))
+
+ class FakeArgs:
+ varname = ["output", bad_output]
+
+ class FakeGlobals:
+ profile = "unit_test"
+
+ cmd._run_main(FakeArgs(), FakeGlobals())
+ _, conf_path = cmd._profile_existed("unit_test.configure")
+ data = json.loads(open(conf_path).read())
+ assert data["_sys_param"]["output"] == "json"
+
+
+@pytest.mark.parametrize("bad_lang", ["fr-FR", "ja-JP", ""])
+def test_configure_set_invalid_language_falls_back_to_zh_cn(tmp_path, monkeypatch, bad_lang):
+ cmd = _make_set_cmd()
+ monkeypatch.setattr(cmd, "cli_path", str(tmp_path))
+
+ class FakeArgs:
+ varname = ["language", bad_lang]
+
+ class FakeGlobals:
+ profile = "unit_test"
+
+ cmd._run_main(FakeArgs(), FakeGlobals())
+ _, conf_path = cmd._profile_existed("unit_test.configure")
+ data = json.loads(open(conf_path).read())
+ assert data["_sys_param"]["language"] == "zh-CN"
+
+
+def test_configure_set_odd_varname_raises(tmp_path, monkeypatch):
+ cmd = _make_set_cmd()
+ monkeypatch.setattr(cmd, "cli_path", str(tmp_path))
+
+ class FakeArgs:
+ varname = ["region"] # 奇数个参数
+
+ class FakeGlobals:
+ profile = "unit_test"
+
+ with pytest.raises(ParamError):
+ cmd._run_main(FakeArgs(), FakeGlobals())
+
+
+# ── ConfigureGetCommand:字段不存在时抛 ConfigurationError ────────────────────
+
+def test_configure_get_nonexistent_key_raises(tmp_path, monkeypatch):
+ cmd = ConfigureGetCommand()
+ monkeypatch.setattr(cmd, "cli_path", str(tmp_path))
+
+ conf_path = tmp_path / "unit_test.configure"
+ conf_path.write_text(json.dumps({"_sys_param": {"region": "ap-guangzhou"}}))
+ cred_path = tmp_path / "unit_test.credential"
+ cred_path.write_text(json.dumps({}))
+
+ class FakeArgs:
+ varname = ["nonexistent_key"]
+
+ class FakeGlobals:
+ profile = "unit_test"
+
+ with pytest.raises(ConfigurationError):
+ cmd._run_main(FakeArgs(), FakeGlobals())
+
+
+def test_configure_get_existing_key_outputs_value(tmp_path, monkeypatch):
+ stream = io.StringIO()
+ cmd = ConfigureGetCommand(stream=stream)
+ monkeypatch.setattr(cmd, "cli_path", str(tmp_path))
+
+ conf_path = tmp_path / "unit_test.configure"
+ conf_path.write_text(json.dumps({"_sys_param": {"region": "ap-beijing"}}))
+ cred_path = tmp_path / "unit_test.credential"
+ cred_path.write_text(json.dumps({}))
+
+ class FakeArgs:
+ varname = ["region"]
+
+ class FakeGlobals:
+ profile = "unit_test"
+
+ cmd._run_main(FakeArgs(), FakeGlobals())
+ assert "region = ap-beijing" in stream.getvalue()
+
+
+# ── 集成测试(依赖已安装的 tccli,不发真实 API 请求)────────────────────────────
+
+def test_configure_list_output_structure():
+ output = shell("tccli configure list")
+ assert "credential:" in output
+ assert "configure:" in output
+ assert "cvm.version =" in output
+ assert "cvm.endpoint =" in output
+
+
+@recover_profile()
+def test_configure_get_set_region():
+ shell("tccli configure set region ap-guangzhou")
+ assert "region = ap-guangzhou" in shell("tccli configure get region")
+
+
+@recover_profile()
+def test_configure_set_output_valid_values():
+ for fmt in ["json", "text", "table"]:
+ shell("tccli configure set output %s" % fmt)
+ assert ("output = %s" % fmt) in shell("tccli configure get output")
+
+
+@recover_profile()
+def test_configure_set_secretid_and_get():
+ sec_id = "AKIDabcdefghijklmn1234"
+ shell("tccli configure set secretId %s" % sec_id)
+ output = shell("tccli configure get secretId")
+ assert sec_id in output
+
+
+@recover_profile("user2")
+def test_configure_profile_isolation():
+ shell("tccli configure --profile user2 set region ap-shanghai")
+ assert "region = ap-shanghai\n" == shell("tccli configure --profile user2 get region")
+ assert "region = ap-shanghai\n" == shell("TCCLI_PROFILE=user2 tccli configure get region")
diff --git a/tests/test_cvm.py b/tests/test_cvm.py
index afde580189..306f47499d 100644
--- a/tests/test_cvm.py
+++ b/tests/test_cvm.py
@@ -1,76 +1,36 @@
-from multiprocessing import Process, Queue
-from tccli.command import CLICommand
-from utils import TestCli
+from utils import shell
+
def test_describe_regions():
- cmd = 'tccli cvm DescribeRegions'
- expect = "\"Region\": \"ap-guangzhou\""
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+ assert "\"Region\": \"ap-guangzhou\"" in shell("tccli cvm DescribeRegions")
def test_describe_instances():
- cmd = 'tccli cvm DescribeInstances'
- expect = "\"TotalCount\":"
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+ assert "\"TotalCount\":" in shell("tccli cvm DescribeInstances")
def test_describe_disaster_disaster_recover_group_quota():
- cmd = 'tccli cvm DescribeDisasterRecoverGroupQuota'
- expect = "\"CvmInHostGroupQuota\":"
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+ assert "\"CvmInHostGroupQuota\":" in shell("tccli cvm DescribeDisasterRecoverGroupQuota")
def test_describe_disaster_recover_groups():
- cmd = 'tccli cvm DescribeDisasterRecoverGroups'
- expect = "DisasterRecoverGroupSet"
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+ assert "DisasterRecoverGroupSet" in shell("tccli cvm DescribeDisasterRecoverGroups")
def test_describe_hosts():
- cmd = 'tccli cvm DescribeHosts --cli-unfold-argument'
+ cmd = 'tccli cvm DescribeHosts --cli-unfold-argument --version 2017-03-12'
cmd += ' --Filters.0.Name zone'
cmd += ' --Filters.0.Values ap-guangzhou-2'
cmd += ' --Offset 0 --Limit 20'
- expect = "\"HostSet\": []"
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+
+ assert "\"HostSet\": []" in shell(cmd)
def test_create_image_dry_run():
cmd = 'tccli cvm CreateImage --ImageName test-image --DryRun true'
- expect = "\"RequestId\": "
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+ assert "\"RequestId\": " in shell(cmd)
def test_create_image_dry_run_with_unfold_argument():
cmd = 'tccli cvm CreateImage --cli-unfold-argument --ImageName test-image --DryRun true'
- expect = "\"RequestId\": "
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
-
-
-def test_multi_process():
- queue = Queue()
- def run_cvm_describe_regions(idx, q):
- args = ["cvm", "DescribeRegions"]
- try:
- CLICommand()(args)
- q.put(0)
- except Exception:
- q.put(255)
-
- processes = []
- for i in range(10):
- p = Process(target=run_cvm_describe_regions, args=(i, queue))
- p.start()
- processes.append(p)
- for p in processes:
- p.join()
- while not queue.empty():
- ret = queue.get()
- assert ret == 0
+ assert "\"RequestId\": " in shell(cmd)
diff --git a/tests/test_helper.py b/tests/test_helper.py
index eda2567f46..5f6727c170 100644
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -1,30 +1,30 @@
# -*- coding:utf8 -*-
-from utils import TestCli
+from utils import shell
def test_help():
cmd = 'tccli help'
expect = 'tccli [options] [options] [options] [options and parameters]'
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+
+ assert expect in shell(cmd)
def test_help_detail():
cmd = 'tccli help --detail'
expect = 'cvm\n 介绍如何使用API对云服务器进行操作,包括使用并管理实例、镜像、密钥等资源。'
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+
+ assert expect in shell(cmd)
def test_cvm_help():
- cmd = 'tccli cvm help'
+ cmd = 'tccli cvm help --version 2017-03-12'
expect = ''
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+
+ assert expect in shell(cmd)
def test_cvm_help_detail():
- cmd = 'tccli cvm help --detail'
+ cmd = 'tccli cvm help --detail --version 2017-03-12'
expect = 'AVAILABLE ACTIONS\n AllocateHosts'
- test_cli = TestCli()
- test_cli.equal(cmd, expect)
+
+ assert expect in shell(cmd)
diff --git a/tests/test_sso.py b/tests/test_sso.py
new file mode 100644
index 0000000000..4b459dba5c
--- /dev/null
+++ b/tests/test_sso.py
@@ -0,0 +1,12 @@
+# -*- coding:utf8 -*-
+
+from utils import shell, recover_profile
+
+
+@recover_profile()
+def test_sso_configure_and_logout():
+ url = "https://tencentcloudsso.com/test/login"
+ assert ("url 已配置为 '%s', 接下来可以使用 `tccli sso login` 进行登陆\n" % url ==
+ shell("tccli sso configure --url %s" % url))
+
+ assert "登出成功, 密钥凭证已被删除:" in shell("tccli sso logout")
diff --git a/tests/utils.py b/tests/utils.py
index 383882ead4..cfbc77a24f 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,7 +1,111 @@
+import contextlib
+import functools
import os
+import subprocess
+import sys
+
+
+def shell(cmd):
+ if isinstance(cmd, str):
+ args = cmd
+ use_shell = True
+ else:
+ args = cmd
+ use_shell = False
+ p = subprocess.Popen(args, shell=use_shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout, _ = p.communicate()
+
+ if sys.version_info.major >= 3:
+ return stdout.decode("utf-8")
+
+ return stdout
+
+
+_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+_TCCLI_RUNNER = (
+ "{py} -c \"import sys; sys.path.insert(0, '{repo}'); "
+ "from tccli.main import main; sys.exit(main())\""
+).format(py=sys.executable, repo=_REPO_ROOT)
+
+
+def shell_with_stderr(cmd, clean_cred_env=False):
+ """执行命令,返回 (stdout, stderr, returncode)。
+ 将命令中的 tccli 替换为当前源码入口,确保测试当前分支代码。
+ clean_cred_env=True 时从 env 中移除 AK/SK,用于测试无凭证场景。
+ """
+ cmd = cmd.replace("tccli ", _TCCLI_RUNNER + " ", 1)
+ env = os.environ.copy()
+ if clean_cred_env:
+ env.pop("TENCENTCLOUD_SECRET_ID", None)
+ env.pop("TENCENTCLOUD_SECRET_KEY", None)
+ env.pop("TENCENTCLOUD_SECRET_TOKEN", None)
+ p = subprocess.Popen(
+ cmd, shell=True,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=env,
+ )
+ stdout, stderr = p.communicate()
+ if sys.version_info.major >= 3:
+ return stdout.decode("utf-8"), stderr.decode("utf-8"), p.returncode
+ return stdout, stderr, p.returncode
+
+
+def recover_profile(prof="default"):
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ with recover_profile_ctx(prof):
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+@contextlib.contextmanager
+def recover_profile_ctx(prof="default"):
+ cred_path = os.path.expanduser("~/.tccli/%s.credential" % prof)
+ conf_path = os.path.expanduser("~/.tccli/%s.configure" % prof)
+
+ cred_backup = None
+ conf_backup = None
+
+ if os.path.exists(cred_path):
+ with open(cred_path, "r") as cred_f:
+ cred_backup = cred_f.readlines()
+
+ if os.path.exists(conf_path):
+ with open(conf_path, "r") as conf_f:
+ conf_backup = conf_f.readlines()
+
+ yield
+
+ if sys.version_info.major == 2:
+ FileNotFound = OSError
+ else:
+ FileNotFound = FileNotFoundError
+
+ if cred_backup:
+ with open(cred_path, "w") as cred_f:
+ cred_f.writelines(cred_backup)
+ else:
+ try:
+ os.remove(cred_path)
+ except FileNotFound:
+ pass
+
+ if conf_backup:
+ with open(conf_path, "w") as conf_f:
+ conf_f.writelines(conf_backup)
+ else:
+ try:
+ os.remove(conf_path)
+ except FileNotFound:
+ pass
class TestCli(object):
+ """保留国内站原有的 TestCli 风格入口,供既有测试沿用。"""
def run_cmd(self, cmd):
result = os.popen(cmd)
@@ -10,5 +114,4 @@ def run_cmd(self, cmd):
def equal(self, cmd, expect):
run = self.run_cmd(cmd)
result = run.read()
- # self.assertEqual(result, expect, "Unexpected Result")
assert result.find(expect) >= 0