pytest-testkit 1.0.0__tar.gz → 1.0.1__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 (59) hide show
  1. {pytest_testkit-1.0.0/pytest_testkit.egg-info → pytest_testkit-1.0.1}/PKG-INFO +44 -1
  2. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/README.md +43 -0
  3. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pyproject.toml +1 -1
  4. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/ssh.py +5 -5
  5. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/device.py +13 -8
  6. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/manager.py +11 -7
  7. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/plugin.py +186 -63
  8. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1/pytest_testkit.egg-info}/PKG-INFO +44 -1
  9. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/SOURCES.txt +1 -0
  10. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/requirements.txt +1 -0
  11. pytest_testkit-1.0.1/tests/test_env_nodes_filter.py +349 -0
  12. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/LICENSE +0 -0
  13. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/MANIFEST.in +0 -0
  14. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/__init__.py +0 -0
  15. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/__init__.py +0 -0
  16. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/__init__.py +0 -0
  17. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/excel.py +0 -0
  18. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/ftp.py +0 -0
  19. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/http.py +0 -0
  20. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/local_exec.py +0 -0
  21. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/sftp.py +0 -0
  22. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/__init__.py +0 -0
  23. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/__init__.py +0 -0
  24. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/allocator.py +0 -0
  25. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/manager.py +0 -0
  26. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/__init__.py +0 -0
  27. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/filters.py +0 -0
  28. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/infra_logger.py +0 -0
  29. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/__init__.py +0 -0
  30. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/action.py +0 -0
  31. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/exceptions.py +0 -0
  32. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/registry.py +0 -0
  33. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/dependency_links.txt +0 -0
  34. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/entry_points.txt +0 -0
  35. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/requires.txt +0 -0
  36. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/top_level.txt +0 -0
  37. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/setup.cfg +0 -0
  38. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_collect_case_with_inifile.py +0 -0
  39. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_creation.py +0 -0
  40. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_exit_docker.py +0 -0
  41. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_getitem.py +0 -0
  42. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_integration.py +0 -0
  43. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_is_interactive_shell.py +0 -0
  44. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_non_shell_commands.py +0 -0
  45. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_sendcmd.py +0 -0
  46. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_sendcmd_interactive.py +0 -0
  47. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_get_inifile_info.py +0 -0
  48. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_get_item_case_info.py +0 -0
  49. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_local_exec.py +0 -0
  50. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_local_interactive.py +0 -0
  51. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_log_factory.py +0 -0
  52. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_log_factory_integration.py +0 -0
  53. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_security_features.py +0 -0
  54. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_cwd.py +0 -0
  55. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_file_ops.py +0 -0
  56. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_ignore_err.py +0 -0
  57. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_only_stdout.py +0 -0
  58. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_remove_command_echo.py +0 -0
  59. {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_success.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest_testkit
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Pytest plugin for Infra logging framework and environment management
5
5
  Author: Huawei
6
6
  License: MIT License
@@ -430,6 +430,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
430
430
  quiet_stdout = true
431
431
  ```
432
432
 
433
+ ### CI 运行元数据(ci_env_info)
434
+
435
+ 执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
436
+ `ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
437
+ 用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
438
+
439
+ #### 字段说明
440
+
441
+ 环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
442
+
443
+ | 字段名 | 含义 | 示例 |
444
+ |:-------------|:-----------------|:------------------|
445
+ | project_name | CI 项目名称 | "openlibing/demo" |
446
+ | workflow_name| 工作流名称 | "nightly" |
447
+ | job_name | 任务(job)名称 | "run-pytest" |
448
+ | run_number | 流水线运行序号 | "128" |
449
+ | step_name | 当前步骤名称 | "pytest-orch" |
450
+
451
+ #### 读取方式
452
+
453
+ ```python
454
+ import json
455
+ import os
456
+
457
+ def test_use_ci_metadata():
458
+ raw = os.environ.get("ci_env_info", "")
459
+ info = json.loads(raw) if raw else {}
460
+ job_name = info.get("job_name", "")
461
+ run_number = info.get("run_number", "")
462
+ # 例如按流水线序号归档产物、按 job 名分支断言逻辑
463
+ assert isinstance(job_name, str)
464
+ ```
465
+
466
+ #### 取值约定
467
+
468
+ - **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
469
+ - **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
470
+ 非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
471
+ - **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
472
+ 不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
473
+ - **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
474
+ 转义,避免经 SSH 传输后乱码。
475
+ - 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
433
476
 
434
477
  ### 支持的 fixture
435
478
 
@@ -372,6 +372,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
372
372
  quiet_stdout = true
373
373
  ```
374
374
 
375
+ ### CI 运行元数据(ci_env_info)
376
+
377
+ 执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
378
+ `ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
379
+ 用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
380
+
381
+ #### 字段说明
382
+
383
+ 环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
384
+
385
+ | 字段名 | 含义 | 示例 |
386
+ |:-------------|:-----------------|:------------------|
387
+ | project_name | CI 项目名称 | "openlibing/demo" |
388
+ | workflow_name| 工作流名称 | "nightly" |
389
+ | job_name | 任务(job)名称 | "run-pytest" |
390
+ | run_number | 流水线运行序号 | "128" |
391
+ | step_name | 当前步骤名称 | "pytest-orch" |
392
+
393
+ #### 读取方式
394
+
395
+ ```python
396
+ import json
397
+ import os
398
+
399
+ def test_use_ci_metadata():
400
+ raw = os.environ.get("ci_env_info", "")
401
+ info = json.loads(raw) if raw else {}
402
+ job_name = info.get("job_name", "")
403
+ run_number = info.get("run_number", "")
404
+ # 例如按流水线序号归档产物、按 job 名分支断言逻辑
405
+ assert isinstance(job_name, str)
406
+ ```
407
+
408
+ #### 取值约定
409
+
410
+ - **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
411
+ - **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
412
+ 非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
413
+ - **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
414
+ 不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
415
+ - **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
416
+ 转义,避免经 SSH 传输后乱码。
417
+ - 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
375
418
 
376
419
  ### 支持的 fixture
377
420
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pytest_testkit"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "Pytest plugin for Infra logging framework and environment management"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -245,8 +245,8 @@ class Ssh:
245
245
  stdin, stdout, stderr = self.target_client.exec_command(
246
246
  cmd_prefix, timeout=param.timeout, environment=param.environment
247
247
  )
248
- stdout_content = stdout.read().decode("utf-8").strip()
249
- stderr_content = stderr.read().decode("utf-8").strip()
248
+ stdout_content = stdout.read().decode("utf-8", errors="replace").strip()
249
+ stderr_content = stderr.read().decode("utf-8", errors="replace").strip()
250
250
  exit_code = stdout.channel.recv_exit_status()
251
251
  self.logger.debug(
252
252
  f"[Ssh._jump_exec_command] executed: {cmd}, exit_code={exit_code}"
@@ -394,7 +394,7 @@ class Ssh:
394
394
  while (
395
395
  channel.recv_ready() and recv_iterations < max_recv_iterations
396
396
  ):
397
- chunk = channel.recv(4096).decode("utf-8")
397
+ chunk = channel.recv(4096).decode("utf-8", errors="replace")
398
398
  if not chunk:
399
399
  break # recv返回空数据,防止死循环
400
400
  echo_output += chunk
@@ -505,8 +505,8 @@ class Ssh:
505
505
  stdin, stdout, stderr = self.client.exec_command(
506
506
  cmd_prefix, timeout=param.timeout, environment=param.environment
507
507
  )
508
- stdout_content = stdout.read().decode("utf-8").strip()
509
- stderr_content = stderr.read().decode("utf-8").strip()
508
+ stdout_content = stdout.read().decode("utf-8", errors="replace").strip()
509
+ stderr_content = stderr.read().decode("utf-8", errors="replace").strip()
510
510
  exit_code = stdout.channel.recv_exit_status()
511
511
  self.logger.debug(
512
512
  f"[Ssh._direct_exec_cmd] executed: {cmd}, exit_code={exit_code}"
@@ -647,8 +647,9 @@ class Device:
647
647
  if isinstance(timeout, str):
648
648
  timeout = int(timeout)
649
649
 
650
- # 第 1 行: 总是打印执行的命令
651
- logger.info(f"[Device.sendcmd] $ {LogColor.CYAN}{cmd}{LogColor.RESET}")
650
+ # 第 1 行: 默认打印执行的命令, 受 echo_output 和 _SESSION_QUIET_STDOUT 控制
651
+ if echo_output and not _SESSION_QUIET_STDOUT:
652
+ logger.info(f"[Device.sendcmd] $ {LogColor.CYAN}{cmd}{LogColor.RESET}")
652
653
 
653
654
  if self.local_exec:
654
655
  logger.debug(f"[Device.sendcmd] local_exec=True, executing locally: {cmd}")
@@ -708,14 +709,17 @@ class Device:
708
709
  # ignore_err=True 时即使底层 success=False 也按成功处理(不打 ERROR)
709
710
  is_failure = (not success) and (not ignore_err)
710
711
 
712
+ # echo_output=False 或 全局静默开关开启时, 任何返回值都不打印
713
+ if not echo_output or _SESSION_QUIET_STDOUT:
714
+ return
715
+
711
716
  parts = []
712
717
  if matched_prompt is not None:
713
718
  parts.append(f"prompt={matched_prompt}")
714
719
  if exit_code is not None:
715
720
  suffix = " (ignored)" if ignore_err and exit_code != 0 else ""
716
721
  parts.append(f"exit={exit_code}{suffix}")
717
- # 全局静默开关一旦开启, 即使用户传了 echo_output=True 也不打印 stdout
718
- if echo_output and stdout and not _SESSION_QUIET_STDOUT:
722
+ if stdout:
719
723
  parts.append(f"stdout={stdout}")
720
724
  if stderr:
721
725
  parts.append(f"stderr={stderr}")
@@ -769,10 +773,11 @@ class Device:
769
773
  if isinstance(timeout, str):
770
774
  timeout = int(timeout)
771
775
 
772
- # 第 1 行: 总是打印执行的命令
773
- logger.info(
774
- f"[Device.sendcmd_interactive] $ {LogColor.CYAN}{cmd}{LogColor.RESET}"
775
- )
776
+ # 第 1 行: 默认打印执行的命令, 受 echo_output 和 _SESSION_QUIET_STDOUT 控制
777
+ if echo_output and not _SESSION_QUIET_STDOUT:
778
+ logger.info(
779
+ f"[Device.sendcmd_interactive] $ {LogColor.CYAN}{cmd}{LogColor.RESET}"
780
+ )
776
781
 
777
782
  if self.local_exec:
778
783
  if not self._get_logged_in():
@@ -2,6 +2,7 @@ import logging
2
2
  import os
3
3
  import re
4
4
  import threading
5
+ import unicodedata
5
6
  from datetime import datetime, timezone
6
7
  from typing import Dict, Optional
7
8
 
@@ -117,16 +118,19 @@ class LogFileManager:
117
118
  return ""
118
119
  try:
119
120
  if b"\xc3" in text.encode("utf-8", errors="replace"):
120
- text = text.encode("latin-1", errors="replace").decode(
121
- "utf-8", errors="replace"
122
- )
121
+ # 严格 latin-1 编码:遇到中文(>U+00FF)会抛错被跳过,避免误伤
122
+ text = text.encode("latin-1").decode("utf-8", errors="replace")
123
123
  except (UnicodeEncodeError, UnicodeDecodeError):
124
124
  logger.debug("latin-1/utf-8 re-decode skipped due to encoding error")
125
- text = "".join(c for c in text if ord(c) < 128 or c in "\n\r\t")
125
+ # 只过滤控制字符,保留中文等合法非 ASCII 字符
126
+ text = "".join(
127
+ c for c in text if c in "\n\r\t" or unicodedata.category(c)[0] != "C"
128
+ )
126
129
  try:
127
- text = text.encode("windows-1252", errors="replace").decode(
128
- "utf-8", errors="replace"
129
- )
130
+ encoded = text.encode("windows-1252")
131
+ # 仅当存在高位字节(疑似 mojibake)时才尝试二次还原
132
+ if any(0x80 <= b <= 0xFF for b in encoded):
133
+ text = encoded.decode("utf-8", errors="replace")
130
134
  except (UnicodeEncodeError, UnicodeDecodeError):
131
135
  logger.debug("windows-1252/utf-8 re-decode skipped due to encoding error")
132
136
  return text
@@ -501,28 +501,28 @@ def _get_inifile_info(config):
501
501
  cfg = configparser.ConfigParser()
502
502
  cfg.read(config.inifile, encoding="utf-8")
503
503
 
504
+ # Split a raw value into tokens. Accept comma, whitespace, or
505
+ # comma+whitespace as separators, e.g. "a,b", "a b", "a, b", "a , b".
506
+ # Empty tokens are dropped.
507
+ def _split_tokens(raw):
508
+ if not raw:
509
+ return []
510
+ return [tok.strip() for tok in re.split(r"[,\s]+", raw) if tok.strip()]
511
+
504
512
  # Read all attributes from [case_info] section
505
- # Split values by comma to support multiple values per attribute
513
+ # Split values to support multiple values per attribute (comma/space separated)
506
514
  aim_attrs = {}
507
515
  if cfg.has_section("case_info"):
508
516
  for key, value in cfg.items("case_info"):
509
- # Split by comma and strip whitespace, filter empty strings
510
- values = [v.strip() for v in value.split(",") if v.strip()]
511
- aim_attrs[key] = values
517
+ aim_attrs[key] = _split_tokens(value)
512
518
 
513
519
  # Read test_ids include/exclude/pre_test
514
- include_ids = cfg.get("test_ids", "include", fallback="")
515
- exclude_ids = cfg.get("test_ids", "exclude", fallback="")
516
- pre_test_ids = cfg.get("pre_test", "pre_test", fallback="")
517
- include_ids = dict.fromkeys(include_ids.split(","), 1)
518
- exclude_ids = dict.fromkeys(exclude_ids.split(","), 1)
519
- pre_test_ids = dict.fromkeys(pre_test_ids.split(","), 1)
520
- include_ids.pop("", None)
521
- exclude_ids.pop("", None)
522
- pre_test_ids.pop("", None)
523
- include_ids = {tid.strip(): 1 for tid in include_ids if tid.strip()}
524
- exclude_ids = {tid.strip(): 1 for tid in exclude_ids if tid.strip()}
525
- pre_test_ids = {tid.strip(): 1 for tid in pre_test_ids if tid.strip()}
520
+ include_raw = cfg.get("test_ids", "include", fallback="")
521
+ exclude_raw = cfg.get("test_ids", "exclude", fallback="")
522
+ pre_test_raw = cfg.get("pre_test", "pre_test", fallback="")
523
+ include_ids = {tid: 1 for tid in _split_tokens(include_raw)}
524
+ exclude_ids = {tid: 1 for tid in _split_tokens(exclude_raw)}
525
+ pre_test_ids = {tid: 1 for tid in _split_tokens(pre_test_raw)}
526
526
  except Exception as e:
527
527
  logger.info(f"get inifile err: {e}, return default val")
528
528
  return {}, {}, {}, {}
@@ -576,10 +576,13 @@ def _collect_case_with_inifile(config, items):
576
576
  """
577
577
  Filter test cases based on pytest.ini configuration.
578
578
 
579
- Filtering logic:
580
- 1. test_ids include/exclude have highest priority
581
- 2. case_info attributes: case must contain all attributes configured in ini with matching values (subset match)
582
- 3. Attributes not configured in ini match all cases
579
+ Filtering logic (priority high -> low):
580
+ 1. include_ids: if configured, only keep matching items (ordered by ini) and return
581
+ 2. case_info attributes: subset match if configured
582
+ 3. exclude_ids: drop matching items from whatever was kept above (or from all items)
583
+ 4. Otherwise keep all items
584
+
585
+ All branches finally reorder by pre_test_ids.
583
586
  """
584
587
  # Get ini file filtering criteria
585
588
  aim_attrs, include_ids, exclude_ids, pre_test_ids = _get_inifile_info(config)
@@ -657,56 +660,69 @@ def _collect_case_with_inifile(config, items):
657
660
  _apply_pre_test_order(items, pre_test_ids)
658
661
  return
659
662
 
660
- if exclude_ids:
661
- items[:] = exclude_list
662
- deselected = [item for item in original_items if item not in exclude_list]
663
- if deselected:
664
- config.hook.pytest_deselected(items=deselected)
665
- logger.info(
666
- f"filter case with exclude id: exclude id list: [{exclude_ids}]\n"
667
- f"final id list: {exclude_list}"
668
- )
669
- _apply_pre_test_order(items, pre_test_ids)
670
- return
663
+ # Step 2: filter by case_info attributes if configured (subset match)
664
+ # If aim_attrs is empty, keep all items for now and let exclude_ids handle removal.
665
+ if aim_attrs:
666
+ attr_filtered_items = []
667
+ for item in items:
668
+ case_info_marker = item.get_closest_marker("case_info")
669
+ if not case_info_marker:
670
+ logger.info(f"item: {item}, match no case_info case")
671
+ continue
671
672
 
672
- # If no attributes configured, match all cases
673
- if not aim_attrs:
674
- logger.info("inifile no case_info attributes, match all case")
675
- _apply_pre_test_order(items, pre_test_ids)
676
- return
673
+ case_attrs = _get_item_case_info(case_info_marker)
677
674
 
678
- # Filter by case_info attributes
679
- # Match if case contains all attributes configured in ini with matching values
680
- filtered_items = []
681
- for item in items:
682
- case_info_marker = item.get_closest_marker("case_info")
683
- if not case_info_marker:
684
- logger.info(f"item: {item}, match no case_info case")
685
- continue
675
+ # Check if all aim_attrs are present in case_attrs with matching values
676
+ # aim_attrs values are lists, case must match one of the values
677
+ all_match = True
678
+ for attr_k, aim_values in aim_attrs.items():
679
+ case_value = case_attrs.get(attr_k)
680
+ if case_value is None or case_value not in aim_values:
681
+ all_match = False
682
+ break
686
683
 
687
- case_attrs = _get_item_case_info(case_info_marker)
684
+ logger.info(f"item: match[{all_match}]{item}[{case_attrs}]")
688
685
 
689
- # Check if all aim_attrs are present in case_attrs with matching values
690
- # aim_attrs values are lists, case must match one of the values
691
- all_match = True
692
- for attr_k, aim_values in aim_attrs.items():
693
- case_value = case_attrs.get(attr_k)
694
- if case_value is None or case_value not in aim_values:
695
- all_match = False
696
- break
686
+ if all_match:
687
+ attr_filtered_items.append(item)
697
688
 
698
- logger.info(f"item: match[{all_match}]{item}[{case_attrs}]")
689
+ items[:] = attr_filtered_items
690
+ deselected = [
691
+ item for item in original_items if item not in attr_filtered_items
692
+ ]
693
+ if deselected:
694
+ config.hook.pytest_deselected(items=deselected)
695
+ logger.info(
696
+ f"filter case with inifile: attributes[{aim_attrs}]\nfiltered cases: {items}"
697
+ )
699
698
 
700
- if all_match:
701
- filtered_items.append(item)
699
+ # Step 3: apply exclude_ids if configured (drop matching items)
700
+ if exclude_ids:
701
+ # Build set of items currently kept, then drop any whose id is in exclude_ids.
702
+ kept_items = []
703
+ excluded_items = []
704
+ for item in items:
705
+ nodeid_parts = item.nodeid.split("::")
706
+ if len(nodeid_parts) >= 2:
707
+ item_full_id = "::".join(nodeid_parts[1:])
708
+ item_method_id = nodeid_parts[-1]
709
+ if exclude_ids.get(item_full_id, None) or exclude_ids.get(
710
+ item_method_id, None
711
+ ):
712
+ excluded_items.append(item)
713
+ continue
714
+ kept_items.append(item)
715
+
716
+ items[:] = kept_items
717
+ if excluded_items:
718
+ config.hook.pytest_deselected(items=excluded_items)
719
+ logger.info(
720
+ f"filter case with exclude id: exclude id list: [{list(exclude_ids.keys())}]\n"
721
+ f"excluded items: {[item.nodeid for item in excluded_items]}"
722
+ )
723
+ logger.info(f"final id list: {kept_items}")
702
724
 
703
- items[:] = filtered_items
704
- deselected = [item for item in original_items if item not in filtered_items]
705
- if deselected:
706
- config.hook.pytest_deselected(items=deselected)
707
- logger.info(
708
- f"filter case with inifile: attributes[{aim_attrs}]\nfiltered cases: {items}"
709
- )
725
+ # Final: reorder by pre_test_ids
710
726
  _apply_pre_test_order(items, pre_test_ids)
711
727
 
712
728
 
@@ -729,12 +745,118 @@ def _get_item_env_info(env_marker):
729
745
  raise Exception("get None template")
730
746
 
731
747
 
748
+ def _parse_env_nodes_config(config):
749
+ """
750
+ Parse env_nodes from [env_info] section of pytest.ini.
751
+
752
+ The env_nodes key is declared under [env_info] (a user-defined section,
753
+ not recognized by pytest.getini), so we read it directly via configparser
754
+ from the ini file pytest is using.
755
+
756
+ Format: '<template_type>.<node_name>' entries, comma-separated.
757
+ Example: 't1.n1, t1.n2, t2.n3' -> {t1: {n1, n2}, t2: {n3}}.
758
+
759
+ Returns:
760
+ dict[str, set[str]]: template_type -> set of node_names to keep.
761
+ Empty dict when not configured or section missing.
762
+ """
763
+ inifile = getattr(config, "inifile", None)
764
+ if not inifile:
765
+ return {}
766
+
767
+ parser = configparser.ConfigParser()
768
+ try:
769
+ parser.read(inifile, encoding="utf-8")
770
+ except configparser.Error as e:
771
+ logger.info(f"parse inifile for env_info err: {e}, return default val")
772
+ return {}
773
+
774
+ if not parser.has_section("env_info"):
775
+ return {}
776
+
777
+ raw = parser.get("env_info", "env_nodes", fallback="").strip()
778
+ if not raw:
779
+ return {}
780
+
781
+ result: dict = {}
782
+ for entry in raw.split(","):
783
+ entry = entry.strip()
784
+ if not entry:
785
+ continue
786
+ if "." not in entry:
787
+ logger.warning(
788
+ f"env_nodes entry ignored (missing '.'): {entry!r}, "
789
+ f"expected format '<template_type>.<node_name>'"
790
+ )
791
+ continue
792
+ ttype, node_name = entry.split(".", 1)
793
+ ttype = ttype.strip()
794
+ node_name = node_name.strip()
795
+ if not ttype or not node_name:
796
+ logger.warning(f"env_nodes entry ignored (empty parts): {entry!r}")
797
+ continue
798
+ result.setdefault(ttype, set()).add(node_name)
799
+
800
+ return result
801
+
802
+
803
+ def _apply_env_nodes_filter(env_info: dict, env_nodes_cfg: dict) -> dict:
804
+ """
805
+ Filter env_info according to env_nodes_cfg.
806
+
807
+ Rules:
808
+ - template_type not in cfg: leave env_info unchanged (no processing).
809
+ - template_type in cfg:
810
+ * Keep only nodes listed in cfg[template_type]; discard others.
811
+ * If any configured node name is missing from env_definition -> raise.
812
+ * After filtering, env_definition must still contain at least one machine
813
+ (sum of machines across kept nodes > 0); otherwise raise.
814
+
815
+ Returns the (possibly mutated) env_info dict.
816
+ """
817
+ if not env_nodes_cfg:
818
+ return env_info
819
+
820
+ ttype = env_info.get("template_type")
821
+ if ttype not in env_nodes_cfg:
822
+ return env_info
823
+
824
+ allowed = env_nodes_cfg[ttype]
825
+ env_def: dict = env_info.get("env_definition", {}) or {}
826
+
827
+ missing = sorted(n for n in allowed if n not in env_def)
828
+ if missing:
829
+ raise ValueError(
830
+ f"env_nodes filter for template_type={ttype!r} expects {sorted(allowed)}, "
831
+ f"but env_definition has nodes {sorted(env_def.keys())}; "
832
+ f"missing: {missing}"
833
+ )
834
+
835
+ filtered_def = {n: env_def[n] for n in env_def if n in allowed}
836
+
837
+ total_machines = sum(
838
+ len(machines)
839
+ for machines in filtered_def.values()
840
+ if isinstance(machines, list)
841
+ )
842
+ if total_machines == 0:
843
+ raise ValueError(
844
+ f"env_nodes filter for template_type={ttype!r} kept {sorted(filtered_def)}, "
845
+ f"but the resulting env_definition contains zero machines. "
846
+ f"Check env_nodes config and template definitions."
847
+ )
848
+
849
+ env_info["env_definition"] = filtered_def
850
+ return env_info
851
+
852
+
732
853
  def _collect_env_and_remote_for_testcase(config, items):
733
854
  """
734
855
  收集过滤后的用例的环境信息
735
856
  """
736
857
  try:
737
858
  env_var_name = config.getoption("--env-var", "COLLECTED_INFO")
859
+ env_nodes_cfg = _parse_env_nodes_config(config)
738
860
 
739
861
  if not hasattr(config, "collected_infos"):
740
862
  config.collected_infos = {} # 用于报错收集后的信息
@@ -744,6 +866,7 @@ def _collect_env_and_remote_for_testcase(config, items):
744
866
  if env_marker:
745
867
  try:
746
868
  env_info = _get_item_env_info(env_marker)
869
+ env_info = _apply_env_nodes_filter(env_info, env_nodes_cfg)
747
870
  remote_marker = item.get_closest_marker("remote_run")
748
871
  env_info.update({"remote_run": bool(remote_marker)})
749
872
  config.collected_infos[item.nodeid] = env_info
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest_testkit
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Pytest plugin for Infra logging framework and environment management
5
5
  Author: Huawei
6
6
  License: MIT License
@@ -430,6 +430,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
430
430
  quiet_stdout = true
431
431
  ```
432
432
 
433
+ ### CI 运行元数据(ci_env_info)
434
+
435
+ 执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
436
+ `ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
437
+ 用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
438
+
439
+ #### 字段说明
440
+
441
+ 环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
442
+
443
+ | 字段名 | 含义 | 示例 |
444
+ |:-------------|:-----------------|:------------------|
445
+ | project_name | CI 项目名称 | "openlibing/demo" |
446
+ | workflow_name| 工作流名称 | "nightly" |
447
+ | job_name | 任务(job)名称 | "run-pytest" |
448
+ | run_number | 流水线运行序号 | "128" |
449
+ | step_name | 当前步骤名称 | "pytest-orch" |
450
+
451
+ #### 读取方式
452
+
453
+ ```python
454
+ import json
455
+ import os
456
+
457
+ def test_use_ci_metadata():
458
+ raw = os.environ.get("ci_env_info", "")
459
+ info = json.loads(raw) if raw else {}
460
+ job_name = info.get("job_name", "")
461
+ run_number = info.get("run_number", "")
462
+ # 例如按流水线序号归档产物、按 job 名分支断言逻辑
463
+ assert isinstance(job_name, str)
464
+ ```
465
+
466
+ #### 取值约定
467
+
468
+ - **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
469
+ - **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
470
+ 非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
471
+ - **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
472
+ 不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
473
+ - **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
474
+ 转义,避免经 SSH 传输后乱码。
475
+ - 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
433
476
 
434
477
  ### 支持的 fixture
435
478
 
@@ -41,6 +41,7 @@ tests/test_docker_proxy_is_interactive_shell.py
41
41
  tests/test_docker_proxy_non_shell_commands.py
42
42
  tests/test_docker_proxy_sendcmd.py
43
43
  tests/test_docker_proxy_sendcmd_interactive.py
44
+ tests/test_env_nodes_filter.py
44
45
  tests/test_get_inifile_info.py
45
46
  tests/test_get_item_case_info.py
46
47
  tests/test_local_exec.py
@@ -1,4 +1,5 @@
1
1
  pytest==8.3.2
2
+ pytest-timeout>=2.3.1,<3.0
2
3
  requests
3
4
  paramiko
4
5
  xlrd>=2.0.0
@@ -0,0 +1,349 @@
1
+ """
2
+ Tests for env_nodes filter applied during pytest_collection_modifyitems.
3
+
4
+ env_nodes is configured in pytest.ini under [env_info] section (a user-defined
5
+ section that pytest.getini does NOT expose, so the plugin reads it via
6
+ configparser from config.inifile).
7
+
8
+ Format:
9
+
10
+ env_nodes = <template_type>.<node_name>[, <template_type>.<node_name>...]
11
+
12
+ Behavior:
13
+ - template_type not in cfg: env_info unchanged
14
+ - template_type in cfg:
15
+ * only keep listed nodes in env_definition
16
+ * if any configured node missing from env_definition -> ValueError
17
+ * after filtering, env_definition must contain >=1 machine total -> ValueError
18
+ """
19
+
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+ from pytest_testkit.plugin import (
25
+ _apply_env_nodes_filter,
26
+ _parse_env_nodes_config,
27
+ )
28
+
29
+
30
+ def _write_ini(content):
31
+ """Write a temp ini file and return its path."""
32
+ fd, path = tempfile.mkstemp(suffix=".ini")
33
+ try:
34
+ with open(fd, "w", encoding="utf-8") as f:
35
+ f.write(content)
36
+ except Exception:
37
+ Path(path).unlink(missing_ok=True)
38
+ raise
39
+ return path
40
+
41
+
42
+ class MockConfig:
43
+ """Minimal pytest Config mock that exposes inifile and getini (any key)."""
44
+
45
+ def __init__(self, inifile=""):
46
+ self.inifile = inifile
47
+
48
+ def getini(self, key):
49
+ # The plugin no longer relies on getini for env_nodes, but keep it
50
+ # so it behaves like a real pytest Config when the plugin calls it.
51
+ return ""
52
+
53
+
54
+ # -----------------------------_parse_env_nodes_config----------------------------
55
+
56
+
57
+ def test_parse_returns_empty_when_no_inifile():
58
+ cfg = MockConfig(inifile="")
59
+ assert _parse_env_nodes_config(cfg) == {}
60
+
61
+
62
+ def test_parse_returns_empty_when_inifile_missing():
63
+ cfg = MockConfig(inifile="/nonexistent/path/to.ini")
64
+ assert _parse_env_nodes_config(cfg) == {}
65
+
66
+
67
+ def test_parse_returns_empty_when_no_env_info_section():
68
+ path = _write_ini("[other_section]\nfoo = bar\n")
69
+ try:
70
+ cfg = MockConfig(inifile=path)
71
+ assert _parse_env_nodes_config(cfg) == {}
72
+ finally:
73
+ Path(path).unlink()
74
+
75
+
76
+ def test_parse_returns_empty_when_env_info_section_has_no_env_nodes_key():
77
+ path = _write_ini("[env_info]\nother_key = value\n")
78
+ try:
79
+ cfg = MockConfig(inifile=path)
80
+ assert _parse_env_nodes_config(cfg) == {}
81
+ finally:
82
+ Path(path).unlink()
83
+
84
+
85
+ def test_parse_single_entry():
86
+ path = _write_ini(
87
+ "[pytest]\nfoo = bar\n\n[env_info]\nenv_nodes = single_device.nodes_A2\n"
88
+ )
89
+ try:
90
+ cfg = MockConfig(inifile=path)
91
+ assert _parse_env_nodes_config(cfg) == {"single_device": {"nodes_A2"}}
92
+ finally:
93
+ Path(path).unlink()
94
+
95
+
96
+ def test_parse_multiple_entries_same_template():
97
+ path = _write_ini("[env_info]\nenv_nodes = t1.n1, t1.n2, t1.n3\n")
98
+ try:
99
+ cfg = MockConfig(inifile=path)
100
+ assert _parse_env_nodes_config(cfg) == {"t1": {"n1", "n2", "n3"}}
101
+ finally:
102
+ Path(path).unlink()
103
+
104
+
105
+ def test_parse_multiple_template_types():
106
+ path = _write_ini("[env_info]\nenv_nodes = t1.n1, t1.n2, t2.n3\n")
107
+ try:
108
+ cfg = MockConfig(inifile=path)
109
+ assert _parse_env_nodes_config(cfg) == {"t1": {"n1", "n2"}, "t2": {"n3"}}
110
+ finally:
111
+ Path(path).unlink()
112
+
113
+
114
+ def test_parse_extra_whitespace_tolerated():
115
+ path = _write_ini("[env_info]\nenv_nodes = t1.n1 , t2.n2 \n")
116
+ try:
117
+ cfg = MockConfig(inifile=path)
118
+ assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}, "t2": {"n2"}}
119
+ finally:
120
+ Path(path).unlink()
121
+
122
+
123
+ def test_parse_malformed_entry_ignored_but_valid_kept():
124
+ # 'no_dot', 'empty.', '.empty' are ignored; only 't1.n1' is kept.
125
+ path = _write_ini("[env_info]\nenv_nodes = no_dot, t1.n1, empty., .empty\n")
126
+ try:
127
+ cfg = MockConfig(inifile=path)
128
+ assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}}
129
+ finally:
130
+ Path(path).unlink()
131
+
132
+
133
+ def test_parse_node_name_can_contain_dot():
134
+ # split with maxsplit=1, so 't1.foo.bar' -> ('t1', 'foo.bar').
135
+ path = _write_ini("[env_info]\nenv_nodes = t1.foo.bar\n")
136
+ try:
137
+ cfg = MockConfig(inifile=path)
138
+ assert _parse_env_nodes_config(cfg) == {"t1": {"foo.bar"}}
139
+ finally:
140
+ Path(path).unlink()
141
+
142
+
143
+ def test_parse_strips_whitespace_inside_entries():
144
+ path = _write_ini("[env_info]\nenv_nodes = t1. n1 , t2.n2\n")
145
+ try:
146
+ cfg = MockConfig(inifile=path)
147
+ assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}, "t2": {"n2"}}
148
+ finally:
149
+ Path(path).unlink()
150
+
151
+
152
+ def test_parse_env_nodes_in_other_section_ignored():
153
+ # env_nodes written under [pytest] should NOT be picked up: the plugin
154
+ # only reads [env_info] section.
155
+ path = _write_ini("[pytest]\nenv_nodes = t1.n1\n")
156
+ try:
157
+ cfg = MockConfig(inifile=path)
158
+ assert _parse_env_nodes_config(cfg) == {}
159
+ finally:
160
+ Path(path).unlink()
161
+
162
+
163
+ def test_parse_full_pytest_ini_layout():
164
+ # Realistic pytest.ini: [pytest] for builtins + [env_info] for ours.
165
+ path = _write_ini(
166
+ "[pytest]\n"
167
+ "infra_log_dir = logs\n"
168
+ "infra_log_level = INFO\n"
169
+ "error_log_fail = true\n"
170
+ "\n"
171
+ "[env_info]\n"
172
+ "env_nodes = single_device.nodes_1, multi_device.nodes_main\n"
173
+ )
174
+ try:
175
+ cfg = MockConfig(inifile=path)
176
+ assert _parse_env_nodes_config(cfg) == {
177
+ "single_device": {"nodes_1"},
178
+ "multi_device": {"nodes_main"},
179
+ }
180
+ finally:
181
+ Path(path).unlink()
182
+
183
+
184
+ # ---------------------------_apply_env_nodes_filter----------------------------
185
+
186
+
187
+ def _make_env_info(template_type, env_definition):
188
+ return {"template_type": template_type, "env_definition": env_definition}
189
+
190
+
191
+ def test_filter_skipped_when_template_type_not_in_cfg():
192
+ env_info = _make_env_info(
193
+ "other_type",
194
+ {"nodes_A": [{"name": "device_a"}]},
195
+ )
196
+ cfg = {"single_device": {"nodes_A2"}}
197
+ out = _apply_env_nodes_filter(env_info, cfg)
198
+ assert out is env_info
199
+ assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
200
+
201
+
202
+ def test_filter_skipped_when_cfg_empty():
203
+ env_info = _make_env_info(
204
+ "single_device",
205
+ {"nodes_A": [{"name": "device_a"}]},
206
+ )
207
+ out = _apply_env_nodes_filter(env_info, {})
208
+ assert out is env_info
209
+ assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
210
+
211
+
212
+ def test_filter_keeps_only_configured_nodes():
213
+ env_info = _make_env_info(
214
+ "single_device",
215
+ {
216
+ "nodes_A1": [{"name": "device_a1"}],
217
+ "nodes_A2": [{"name": "device_a2"}],
218
+ "nodes_A3": [{"name": "device_a3"}],
219
+ },
220
+ )
221
+ cfg = {"single_device": {"nodes_A2"}}
222
+ out = _apply_env_nodes_filter(env_info, cfg)
223
+ assert "env_definition" in out
224
+ assert set(out["env_definition"].keys()) == {"nodes_A2"}
225
+ assert out["env_definition"]["nodes_A2"] == [{"name": "device_a2"}]
226
+
227
+
228
+ def test_filter_keeps_multiple_nodes_for_same_template():
229
+ env_info = _make_env_info(
230
+ "t1",
231
+ {
232
+ "n1": [{"name": "d1"}],
233
+ "n2": [{"name": "d2"}],
234
+ "n3": [{"name": "d3"}],
235
+ },
236
+ )
237
+ cfg = {"t1": {"n1", "n3"}}
238
+ out = _apply_env_nodes_filter(env_info, cfg)
239
+ assert set(out["env_definition"].keys()) == {"n1", "n3"}
240
+
241
+
242
+ def test_filter_raises_when_configured_node_missing():
243
+ env_info = _make_env_info(
244
+ "single_device",
245
+ {"nodes_A1": [{"name": "device_a1"}]},
246
+ )
247
+ cfg = {"single_device": {"nodes_A2"}}
248
+ with pytest.raises(ValueError, match="nodes_A2"):
249
+ _apply_env_nodes_filter(env_info, cfg)
250
+
251
+
252
+ def test_filter_raises_when_one_of_many_configured_nodes_missing():
253
+ env_info = _make_env_info(
254
+ "t1",
255
+ {"n1": [{"name": "d1"}]},
256
+ )
257
+ cfg = {"t1": {"n1", "n2"}}
258
+ with pytest.raises(ValueError, match="n2"):
259
+ _apply_env_nodes_filter(env_info, cfg)
260
+
261
+
262
+ def test_filter_raises_when_all_kept_nodes_empty_machine_list():
263
+ env_info = _make_env_info("t1", {"n1": []})
264
+ cfg = {"t1": {"n1"}}
265
+ with pytest.raises(ValueError, match="zero machines"):
266
+ _apply_env_nodes_filter(env_info, cfg)
267
+
268
+
269
+ def test_filter_raises_when_no_node_kept_at_all():
270
+ env_info = _make_env_info("t1", {"other": [{"name": "d"}]})
271
+ cfg = {"t1": {"n1"}}
272
+ with pytest.raises(ValueError, match="missing"):
273
+ _apply_env_nodes_filter(env_info, cfg)
274
+
275
+
276
+ def test_filter_preserves_machine_payload_after_filter():
277
+ env_info = _make_env_info(
278
+ "t1",
279
+ {
280
+ "n1": [{"name": "d1", "pool": "default"}],
281
+ "n2": [{"name": "d2", "pool": "default"}],
282
+ },
283
+ )
284
+ cfg = {"t1": {"n2"}}
285
+ out = _apply_env_nodes_filter(env_info, cfg)
286
+ assert out["env_definition"]["n2"] == [{"name": "d2", "pool": "default"}]
287
+ assert "n1" not in out["env_definition"]
288
+
289
+
290
+ def test_filter_does_not_affect_other_template_types_in_cfg():
291
+ env_info_a = _make_env_info(
292
+ "t1",
293
+ {"n1": [{"name": "d1"}], "n2": [{"name": "d2"}]},
294
+ )
295
+ env_info_b = _make_env_info("t2", {"x": [{"name": "dx"}]})
296
+ cfg = {"t1": {"n1"}, "t2": {"x"}}
297
+ out_a = _apply_env_nodes_filter(env_info_a, cfg)
298
+ out_b = _apply_env_nodes_filter(env_info_b, cfg)
299
+ assert set(out_a["env_definition"].keys()) == {"n1"}
300
+ assert set(out_b["env_definition"].keys()) == {"x"}
301
+
302
+
303
+ def test_filter_with_unrelated_cfg_keys_returns_unchanged():
304
+ env_info = _make_env_info(
305
+ "completely_different",
306
+ {"nodes_A": [{"name": "device_a"}]},
307
+ )
308
+ cfg = {"single_device": {"nodes_A2"}, "multi_device": {"nodes_B1"}}
309
+ out = _apply_env_nodes_filter(env_info, cfg)
310
+ assert out is env_info
311
+ assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
312
+
313
+
314
+ # ---------------------------integration: parse + filter--------------------------
315
+
316
+
317
+ def test_integration_single_template_match():
318
+ path = _write_ini(
319
+ "[env_info]\nenv_nodes = single_device.nodes_1, multi_device.nodes_main\n"
320
+ )
321
+ try:
322
+ cfg = MockConfig(inifile=path)
323
+ parsed = _parse_env_nodes_config(cfg)
324
+
325
+ env_info_single = _make_env_info(
326
+ "single_device",
327
+ {
328
+ "nodes_1": [{"name": "device"}],
329
+ "nodes_2": [{"name": "other"}],
330
+ },
331
+ )
332
+ env_info_multi = _make_env_info(
333
+ "multi_device",
334
+ {
335
+ "nodes_main": [{"name": "m1"}, {"name": "m2"}],
336
+ "nodes_extra": [{"name": "extra"}],
337
+ },
338
+ )
339
+ env_info_other = _make_env_info("local_exec", {"nodes_x": [{"name": "x"}]})
340
+
341
+ out_single = _apply_env_nodes_filter(env_info_single, parsed)
342
+ out_multi = _apply_env_nodes_filter(env_info_multi, parsed)
343
+ out_other = _apply_env_nodes_filter(env_info_other, parsed)
344
+
345
+ assert set(out_single["env_definition"].keys()) == {"nodes_1"}
346
+ assert set(out_multi["env_definition"].keys()) == {"nodes_main"}
347
+ assert out_other["env_definition"] == {"nodes_x": [{"name": "x"}]}
348
+ finally:
349
+ Path(path).unlink()
File without changes
File without changes