ansible-core 2.19.0b6__py3-none-any.whl → 2.19.0b7__py3-none-any.whl

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 (101) hide show
  1. ansible/_internal/_templating/_jinja_bits.py +16 -1
  2. ansible/_internal/_templating/_jinja_common.py +1 -1
  3. ansible/cli/__init__.py +2 -2
  4. ansible/cli/adhoc.py +6 -3
  5. ansible/cli/console.py +1 -1
  6. ansible/cli/doc.py +2 -2
  7. ansible/config/base.yml +9 -6
  8. ansible/executor/module_common.py +8 -5
  9. ansible/executor/powershell/psrp_put_file.ps1 +1 -1
  10. ansible/executor/task_executor.py +2 -2
  11. ansible/executor/task_queue_manager.py +34 -70
  12. ansible/executor/task_result.py +1 -1
  13. ansible/galaxy/api.py +2 -2
  14. ansible/galaxy/collection/concrete_artifact_manager.py +2 -2
  15. ansible/galaxy/dependency_resolution/providers.py +3 -3
  16. ansible/inventory/group.py +6 -1
  17. ansible/inventory/host.py +6 -1
  18. ansible/module_utils/_internal/_deprecator.py +12 -1
  19. ansible/module_utils/ansible_release.py +1 -1
  20. ansible/module_utils/basic.py +14 -16
  21. ansible/module_utils/common/yaml.py +1 -1
  22. ansible/module_utils/csharp/Ansible.Basic.cs +1 -1
  23. ansible/module_utils/csharp/Ansible.Privilege.cs +2 -2
  24. ansible/module_utils/facts/hardware/base.py +1 -1
  25. ansible/module_utils/facts/other/facter.py +1 -1
  26. ansible/module_utils/facts/system/distribution.py +2 -2
  27. ansible/module_utils/powershell/Ansible.ModuleUtils.AddType.psm1 +1 -1
  28. ansible/module_utils/powershell/Ansible.ModuleUtils.CamelConversion.psm1 +1 -1
  29. ansible/module_utils/powershell/Ansible.ModuleUtils.CommandUtil.psm1 +1 -1
  30. ansible/module_utils/powershell/Ansible.ModuleUtils.WebRequest.psm1 +1 -1
  31. ansible/module_utils/urls.py +1 -1
  32. ansible/modules/apt.py +9 -3
  33. ansible/modules/assemble.py +5 -3
  34. ansible/modules/expect.py +5 -5
  35. ansible/modules/hostname.py +2 -2
  36. ansible/modules/pip.py +9 -11
  37. ansible/modules/raw.py +2 -2
  38. ansible/modules/stat.py +1 -1
  39. ansible/modules/wait_for.py +10 -3
  40. ansible/parsing/mod_args.py +38 -20
  41. ansible/parsing/vault/__init__.py +3 -3
  42. ansible/playbook/base.py +0 -2
  43. ansible/playbook/helpers.py +1 -1
  44. ansible/playbook/playbook_include.py +23 -56
  45. ansible/playbook/role/__init__.py +38 -21
  46. ansible/plugins/action/__init__.py +2 -2
  47. ansible/plugins/action/assemble.py +2 -1
  48. ansible/plugins/action/assert.py +2 -2
  49. ansible/plugins/action/script.py +5 -4
  50. ansible/plugins/action/template.py +1 -1
  51. ansible/plugins/callback/__init__.py +77 -87
  52. ansible/plugins/callback/default.py +0 -3
  53. ansible/plugins/callback/junit.py +0 -6
  54. ansible/plugins/connection/ssh.py +1 -1
  55. ansible/plugins/filter/pow.yml +1 -1
  56. ansible/plugins/filter/root.yml +1 -1
  57. ansible/plugins/filter/strftime.yml +3 -3
  58. ansible/plugins/filter/to_uuid.yml +1 -1
  59. ansible/plugins/inventory/script.py +1 -1
  60. ansible/plugins/loader.py +5 -0
  61. ansible/plugins/lookup/password.py +4 -6
  62. ansible/release.py +1 -1
  63. ansible/utils/display.py +16 -26
  64. ansible/utils/path.py +1 -1
  65. ansible/utils/vars.py +4 -1
  66. ansible/vars/manager.py +6 -3
  67. ansible/vars/reserved.py +6 -4
  68. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/METADATA +1 -1
  69. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/RECORD +101 -99
  70. ansible_test/_internal/__init__.py +5 -0
  71. ansible_test/_internal/ansible_util.py +1 -1
  72. ansible_test/_internal/classification/python.py +6 -0
  73. ansible_test/_internal/cli/commands/__init__.py +0 -5
  74. ansible_test/_internal/cli/environments.py +51 -5
  75. ansible_test/_internal/commands/coverage/__init__.py +1 -1
  76. ansible_test/_internal/commands/integration/__init__.py +18 -5
  77. ansible_test/_internal/commands/integration/cloud/httptester.py +1 -1
  78. ansible_test/_internal/commands/sanity/__init__.py +3 -1
  79. ansible_test/_internal/commands/sanity/integration_aliases.py +11 -0
  80. ansible_test/_internal/commands/shell/__init__.py +43 -4
  81. ansible_test/_internal/commands/units/__init__.py +4 -1
  82. ansible_test/_internal/config.py +21 -13
  83. ansible_test/_internal/debugging.py +166 -0
  84. ansible_test/_internal/delegation.py +21 -13
  85. ansible_test/_internal/host_profiles.py +197 -6
  86. ansible_test/_internal/inventory.py +4 -0
  87. ansible_test/_internal/metadata.py +94 -4
  88. ansible_test/_internal/processes.py +80 -0
  89. ansible_test/_internal/python_requirements.py +27 -0
  90. ansible_test/_internal/target.py +8 -0
  91. ansible_test/_internal/util_common.py +13 -3
  92. ansible_test/_util/target/injector/python.py +8 -0
  93. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/WHEEL +0 -0
  94. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/entry_points.txt +0 -0
  95. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/COPYING +0 -0
  96. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/Apache-License.txt +0 -0
  97. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/BSD-3-Clause.txt +0 -0
  98. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/MIT-license.txt +0 -0
  99. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/PSF-license.txt +0 -0
  100. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/licenses/licenses/simplified_bsd.txt +0 -0
  101. {ansible_core-2.19.0b6.dist-info → ansible_core-2.19.0b7.dist-info}/top_level.txt +0 -0
@@ -59,6 +59,10 @@ from .config import (
59
59
  TestConfig,
60
60
  )
61
61
 
62
+ from .debugging import (
63
+ initialize_debugger,
64
+ )
65
+
62
66
 
63
67
  def main(cli_args: t.Optional[list[str]] = None) -> None:
64
68
  """Wrapper around the main program function to invoke cleanup functions at exit."""
@@ -77,6 +81,7 @@ def main_internal(cli_args: t.Optional[list[str]] = None) -> None:
77
81
  display.redact = config.redact
78
82
  display.color = config.color
79
83
  display.fd = sys.stderr if config.display_stderr else sys.stdout
84
+ initialize_debugger(config)
80
85
  configure_timeout(config)
81
86
  report_locale(isinstance(config, TestConfig) and not config.delegate)
82
87
 
@@ -330,6 +330,6 @@ def run_playbook(
330
330
  if args.verbosity:
331
331
  cmd.append('-%s' % ('v' * args.verbosity))
332
332
 
333
- install_requirements(args, args.controller_python, ansible=True) # run_playbook()
333
+ install_requirements(args, None, args.controller_python, ansible=True) # run_playbook()
334
334
  env = ansible_environment(args)
335
335
  intercept_python(args, args.controller_python, cmd, env, capture=capture)
@@ -221,6 +221,12 @@ def relative_to_absolute(name: str, level: int, module: str, path: str, lineno:
221
221
  else:
222
222
  parts = module.split('.')
223
223
 
224
+ if path.endswith('/__init__.py'):
225
+ # Ensure the correct relative module is calculated for both not_init.py and __init__.py:
226
+ # a/b/not_init.py -> a.b.not_init # used as-is
227
+ # a/b/__init__.py -> a.b # needs "__init__" part appended to ensure relative imports work
228
+ parts.append('__init__')
229
+
224
230
  if level >= len(parts):
225
231
  display.warning('Cannot resolve relative import "%s%s" above module "%s" at %s:%d' % ('.' * level, name, module, path, lineno))
226
232
  absolute_name = 'relative.abovelevel'
@@ -162,11 +162,6 @@ def do_commands(
162
162
  help='only verify code coverage can be enabled',
163
163
  )
164
164
 
165
- testing.add_argument(
166
- '--metadata',
167
- help=argparse.SUPPRESS,
168
- )
169
-
170
165
  testing.add_argument(
171
166
  '--base-branch',
172
167
  metavar='BRANCH',
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import enum
7
7
  import functools
8
+ import os
8
9
  import typing as t
9
10
 
10
11
  from ..constants import (
@@ -147,8 +148,20 @@ def add_global_options(
147
148
  help='install command requirements',
148
149
  )
149
150
 
151
+ global_parser.add_argument(
152
+ '--host-path',
153
+ help=argparse.SUPPRESS, # for internal use only by ansible-test
154
+ )
155
+
156
+ global_parser.add_argument(
157
+ '--metadata',
158
+ default=os.environ.get('ANSIBLE_TEST_METADATA_PATH'),
159
+ help=argparse.SUPPRESS, # for internal use only by ansible-test
160
+ )
161
+
150
162
  add_global_remote(global_parser, controller_mode)
151
163
  add_global_docker(global_parser, controller_mode)
164
+ add_global_debug(global_parser)
152
165
 
153
166
 
154
167
  def add_composite_environment_options(
@@ -161,11 +174,6 @@ def add_composite_environment_options(
161
174
  composite_parser = t.cast(argparse.ArgumentParser, parser.add_argument_group(
162
175
  title='composite environment arguments (mutually exclusive with "environment arguments" above)'))
163
176
 
164
- composite_parser.add_argument(
165
- '--host-path',
166
- help=argparse.SUPPRESS,
167
- )
168
-
169
177
  action_types: list[t.Type[CompositeAction]] = []
170
178
 
171
179
  def register_action_type(action_type: t.Type[CompositeAction]) -> t.Type[CompositeAction]:
@@ -440,6 +448,44 @@ def add_global_docker(
440
448
  )
441
449
 
442
450
 
451
+ def add_global_debug(
452
+ parser: argparse.ArgumentParser,
453
+ ) -> None:
454
+ """Add global debug options."""
455
+ # These `--dev-*` options are experimental features that may change or be removed without regard for backward compatibility.
456
+ # Additionally, they're features that are not likely to be used by most users.
457
+ # To avoid confusion, they're hidden from `--help` and tab completion by default, except for ansible-core-ci users.
458
+ suppress = None if get_ci_provider().supports_core_ci_auth() else argparse.SUPPRESS
459
+
460
+ parser.add_argument(
461
+ '--dev-debug-on-demand',
462
+ action='store_true',
463
+ default=False,
464
+ help=suppress or 'enable remote debugging only under a debugger',
465
+ )
466
+
467
+ parser.add_argument(
468
+ '--dev-debug-cli',
469
+ action='store_true',
470
+ default=False,
471
+ help=suppress or 'enable remote debugging for the Ansible CLI',
472
+ )
473
+
474
+ parser.add_argument(
475
+ '--dev-debug-ansiballz',
476
+ action='store_true',
477
+ default=False,
478
+ help=suppress or 'enable remote debugging for AnsiballZ modules',
479
+ )
480
+
481
+ parser.add_argument(
482
+ '--dev-debug-self',
483
+ action='store_true',
484
+ default=False,
485
+ help=suppress or 'enable remote debugging for ansible-test',
486
+ )
487
+
488
+
443
489
  def add_environment_docker(
444
490
  exclusive_parser: argparse.ArgumentParser,
445
491
  environments_parser: argparse.ArgumentParser,
@@ -77,7 +77,7 @@ class CoverageConfig(EnvironmentConfig):
77
77
  def initialize_coverage(args: CoverageConfig, host_state: HostState) -> coverage_module:
78
78
  """Delegate execution if requested, install requirements, then import and return the coverage module. Raises an exception if coverage is not available."""
79
79
  configure_pypi_proxy(args, host_state.controller_profile) # coverage
80
- install_requirements(args, host_state.controller_profile.python, coverage=True) # coverage
80
+ install_requirements(args, host_state.controller_profile, host_state.controller_profile.python, coverage=True) # coverage
81
81
 
82
82
  try:
83
83
  import coverage
@@ -105,6 +105,7 @@ from ...host_profiles import (
105
105
  HostProfile,
106
106
  PosixProfile,
107
107
  SshTargetHostProfile,
108
+ DebuggableProfile,
108
109
  )
109
110
 
110
111
  from ...provisioning import (
@@ -459,10 +460,10 @@ def command_integration_filtered(
459
460
 
460
461
  if isinstance(target_profile, ControllerProfile):
461
462
  if host_state.controller_profile.python.path != target_profile.python.path:
462
- install_requirements(args, target_python, command=True, controller=False) # integration
463
+ install_requirements(args, target_profile, target_python, command=True, controller=False) # integration
463
464
  elif isinstance(target_profile, SshTargetHostProfile):
464
465
  connection = target_profile.get_controller_target_connections()[0]
465
- install_requirements(args, target_python, command=True, controller=False, connection=connection) # integration
466
+ install_requirements(args, target_profile, target_python, command=True, controller=False, connection=connection) # integration
466
467
 
467
468
  coverage_manager = CoverageManager(args, host_state, inventory_path)
468
469
  coverage_manager.setup()
@@ -616,7 +617,7 @@ def command_integration_script(
616
617
  if args.verbosity:
617
618
  cmd.append('-' + ('v' * args.verbosity))
618
619
 
619
- env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env)
620
+ env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env, host_state)
620
621
  cwd = os.path.join(test_env.targets_dir, target.relative_path)
621
622
 
622
623
  env.update(
@@ -737,7 +738,7 @@ def command_integration_role(
737
738
  if args.verbosity:
738
739
  cmd.append('-' + ('v' * args.verbosity))
739
740
 
740
- env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env)
741
+ env = integration_environment(args, target, test_dir, test_env.inventory_path, test_env.ansible_config, env_config, test_env, host_state)
741
742
  cwd = test_env.integration_dir
742
743
 
743
744
  env.update(
@@ -793,6 +794,7 @@ def integration_environment(
793
794
  ansible_config: t.Optional[str],
794
795
  env_config: t.Optional[CloudEnvironmentConfig],
795
796
  test_env: IntegrationEnvironment,
797
+ host_state: HostState,
796
798
  ) -> dict[str, str]:
797
799
  """Return a dictionary of environment variables to use when running the given integration test target."""
798
800
  env = ansible_environment(args, ansible_config=ansible_config)
@@ -813,6 +815,9 @@ def integration_environment(
813
815
  if args.debug_strategy:
814
816
  env.update(ANSIBLE_STRATEGY='debug')
815
817
 
818
+ if isinstance(host_state.controller_profile, DebuggableProfile):
819
+ env.update(host_state.controller_profile.get_ansible_cli_environment_variables())
820
+
816
821
  if 'non_local/' in target.aliases:
817
822
  if args.coverage:
818
823
  display.warning('Skipping coverage reporting on Ansible modules for non-local test: %s' % target.name)
@@ -820,6 +825,7 @@ def integration_environment(
820
825
  env.update(ANSIBLE_TEST_REMOTE_INTERPRETER='')
821
826
 
822
827
  env.update(integration)
828
+ env.update(target.env_set)
823
829
 
824
830
  return env
825
831
 
@@ -974,6 +980,13 @@ def requirements(host_profile: HostProfile) -> None:
974
980
  """Install requirements after bootstrapping and delegation."""
975
981
  if isinstance(host_profile, ControllerHostProfile) and host_profile.controller:
976
982
  configure_pypi_proxy(host_profile.args, host_profile) # integration, windows-integration, network-integration
977
- install_requirements(host_profile.args, host_profile.python, ansible=True, command=True) # integration, windows-integration, network-integration
983
+
984
+ install_requirements( # integration, windows-integration, network-integration
985
+ args=host_profile.args,
986
+ host_profile=host_profile,
987
+ python=host_profile.python,
988
+ ansible=True,
989
+ command=True,
990
+ )
978
991
  elif isinstance(host_profile, PosixProfile) and not isinstance(host_profile, ControllerProfile):
979
992
  configure_pypi_proxy(host_profile.args, host_profile) # integration
@@ -71,7 +71,7 @@ class HttptesterProvider(CloudProvider):
71
71
  return
72
72
 
73
73
  # Read the password from the container environment.
74
- # This allows the tests to work when re-using an existing container.
74
+ # This allows the tests to work when reusing an existing container.
75
75
  # The password is marked as sensitive, since it may differ from the one we generated.
76
76
  krb5_password = descriptor.details.container.env_dict()[KRB5_PASSWORD_ENV]
77
77
  display.sensitive.add(krb5_password)
@@ -76,6 +76,7 @@ from ...python_requirements import (
76
76
  PipInstall,
77
77
  collect_requirements,
78
78
  run_pip,
79
+ install_requirements,
79
80
  )
80
81
 
81
82
  from ...config import (
@@ -178,6 +179,7 @@ def command_sanity(args: SanityConfig) -> None:
178
179
  if args.delegate:
179
180
  raise Delegate(host_state=host_state, require=changes, exclude=args.exclude)
180
181
 
182
+ install_requirements(args, host_state.controller_profile, host_state.controller_profile.python) # sanity
181
183
  configure_pypi_proxy(args, host_state.controller_profile) # sanity
182
184
 
183
185
  if disabled:
@@ -1085,7 +1087,7 @@ class SanityScript(SanityTest, metaclass=abc.ABCMeta):
1085
1087
 
1086
1088
 
1087
1089
  class SanityVersionNeutral(SanityTest, metaclass=abc.ABCMeta):
1088
- """Base class for sanity test plugins which are idependent of the python version being used."""
1090
+ """Base class for sanity test plugins which are independent of the python version being used."""
1089
1091
 
1090
1092
  @abc.abstractmethod
1091
1093
  def test(self, args: SanityConfig, targets: SanityTargets) -> TestResult:
@@ -181,6 +181,8 @@ class IntegrationAliasesTest(SanitySingleVersion):
181
181
  group_numbers = self.ci_test_groups.get(name, None)
182
182
 
183
183
  if group_numbers:
184
+ group_numbers = [num for num in group_numbers if num not in (6, 7)] # HACK: ignore special groups 6 and 7
185
+
184
186
  if min(group_numbers) != 1:
185
187
  display.warning('Min test group "%s" in %s is %d instead of 1.' % (name, self.CI_YML, min(group_numbers)), unique=True)
186
188
 
@@ -291,6 +293,9 @@ class IntegrationAliasesTest(SanitySingleVersion):
291
293
  if target.name == 'ansible-test-container':
292
294
  continue # special test target which uses group 6 -- nothing else should be in that group
293
295
 
296
+ if target.name in ('dnf-oldest', 'dnf-latest'):
297
+ continue # special test targets which use group 7 -- nothing else should be in that group
298
+
294
299
  if f'{self.TEST_ALIAS_PREFIX}/posix/' not in target.aliases:
295
300
  continue
296
301
 
@@ -351,6 +356,12 @@ class IntegrationAliasesTest(SanitySingleVersion):
351
356
  if path == 'test/integration/targets/ansible-test-container':
352
357
  continue # special test target which uses group 6 -- nothing else should be in that group
353
358
 
359
+ if path in (
360
+ 'test/integration/targets/dnf-oldest',
361
+ 'test/integration/targets/dnf-latest',
362
+ ):
363
+ continue # special test targets which use group 7 -- nothing else should be in that group
364
+
354
365
  messages.append(SanityMessage(unassigned_message, '%s/aliases' % path))
355
366
 
356
367
  for path in conflicting_paths:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import os
6
7
  import sys
7
8
  import typing as t
@@ -14,6 +15,10 @@ from ...util import (
14
15
  HostConnectionError,
15
16
  )
16
17
 
18
+ from ...ansible_util import (
19
+ ansible_environment,
20
+ )
21
+
17
22
  from ...config import (
18
23
  ShellConfig,
19
24
  )
@@ -32,6 +37,7 @@ from ...host_profiles import (
32
37
  ControllerProfile,
33
38
  PosixProfile,
34
39
  SshTargetHostProfile,
40
+ DebuggableProfile,
35
41
  )
36
42
 
37
43
  from ...provisioning import (
@@ -39,7 +45,6 @@ from ...provisioning import (
39
45
  )
40
46
 
41
47
  from ...host_configs import (
42
- ControllerConfig,
43
48
  OriginConfig,
44
49
  )
45
50
 
@@ -48,12 +53,21 @@ from ...inventory import (
48
53
  create_posix_inventory,
49
54
  )
50
55
 
56
+ from ...python_requirements import (
57
+ install_requirements,
58
+ )
59
+
60
+ from ...util_common import (
61
+ get_injector_env,
62
+ )
63
+
64
+ from ...delegation import (
65
+ metadata_context,
66
+ )
67
+
51
68
 
52
69
  def command_shell(args: ShellConfig) -> None:
53
70
  """Entry point for the `shell` command."""
54
- if args.raw and isinstance(args.targets[0], ControllerConfig):
55
- raise ApplicationError('The --raw option has no effect on the controller.')
56
-
57
71
  if not args.export and not args.cmd and not sys.stdin.isatty():
58
72
  raise ApplicationError('Standard input must be a TTY to launch a shell.')
59
73
 
@@ -62,6 +76,8 @@ def command_shell(args: ShellConfig) -> None:
62
76
  if args.delegate:
63
77
  raise Delegate(host_state=host_state)
64
78
 
79
+ install_requirements(args, host_state.controller_profile, host_state.controller_profile.python) # shell
80
+
65
81
  if args.raw and not isinstance(args.controller, OriginConfig):
66
82
  display.warning('The --raw option will only be applied to the target.')
67
83
 
@@ -92,6 +108,20 @@ def command_shell(args: ShellConfig) -> None:
92
108
  con.run(args.cmd, capture=False, interactive=False, output_stream=OutputStream.ORIGINAL)
93
109
  return
94
110
 
111
+ if isinstance(con, LocalConnection) and isinstance(target_profile, DebuggableProfile) and target_profile.debugging_enabled:
112
+ # HACK: ensure the pydevd port visible in the shell is the forwarded port, not the original
113
+ args.metadata.debugger_settings = dataclasses.replace(args.metadata.debugger_settings, port=target_profile.pydevd_port)
114
+
115
+ with metadata_context(args):
116
+ interactive_shell(args, target_profile, con)
117
+
118
+
119
+ def interactive_shell(
120
+ args: ShellConfig,
121
+ target_profile: SshTargetHostProfile,
122
+ con: Connection,
123
+ ) -> None:
124
+ """Run an interactive shell."""
95
125
  if isinstance(con, SshConnection) and args.raw:
96
126
  cmd: list[str] = []
97
127
  elif isinstance(target_profile, PosixProfile):
@@ -111,6 +141,15 @@ def command_shell(args: ShellConfig) -> None:
111
141
 
112
142
  env = {name: os.environ[name] for name in optional_vars if name in os.environ}
113
143
 
144
+ if isinstance(con, LocalConnection): # configure the controller environment
145
+ env.update(ansible_environment(args))
146
+ env.update(get_injector_env(target_profile.python, env))
147
+ env.update(ANSIBLE_TEST_METADATA_PATH=os.path.abspath(args.metadata_path))
148
+
149
+ if isinstance(target_profile, DebuggableProfile):
150
+ env.update(target_profile.get_ansiballz_environment_variables())
151
+ env.update(target_profile.get_ansible_cli_environment_variables())
152
+
114
153
  if env:
115
154
  cmd = ['/usr/bin/env'] + [f'{name}={value}' for name, value in env.items()]
116
155
 
@@ -64,6 +64,7 @@ from ...executor import (
64
64
 
65
65
  from ...python_requirements import (
66
66
  install_requirements,
67
+ post_install,
67
68
  )
68
69
 
69
70
  from ...content_config import (
@@ -230,7 +231,9 @@ def command_units(args: UnitsConfig) -> None:
230
231
  controller = any(test_context == TestContext.controller for test_context, python, paths, env in final_candidates)
231
232
 
232
233
  if args.requirements_mode != 'skip':
233
- install_requirements(args, target_profile.python, ansible=controller, command=True, controller=False) # units
234
+ install_requirements(args, target_profile, target_profile.python, ansible=controller, command=True, controller=False) # units
235
+ else:
236
+ post_install(target_profile)
234
237
 
235
238
  test_sets.extend(final_candidates)
236
239
 
@@ -20,6 +20,7 @@ from .util_common import (
20
20
 
21
21
  from .metadata import (
22
22
  Metadata,
23
+ DebuggerFlags,
23
24
  )
24
25
 
25
26
  from .data import (
@@ -118,6 +119,26 @@ class EnvironmentConfig(CommonConfig):
118
119
  self.dev_systemd_debug: bool = args.dev_systemd_debug
119
120
  self.dev_probe_cgroups: t.Optional[str] = args.dev_probe_cgroups
120
121
 
122
+ debugger_flags = DebuggerFlags(
123
+ on_demand=args.dev_debug_on_demand,
124
+ cli=args.dev_debug_cli,
125
+ ansiballz=args.dev_debug_ansiballz,
126
+ self=args.dev_debug_self,
127
+ )
128
+
129
+ self.metadata = Metadata.from_file(args.metadata) if args.metadata else Metadata(debugger_flags=debugger_flags)
130
+ self.metadata_path: t.Optional[str] = None
131
+
132
+ def metadata_callback(payload_config: PayloadConfig) -> None:
133
+ """Add the metadata file to the payload file list."""
134
+ config = self
135
+ files = payload_config.files
136
+
137
+ if config.metadata_path:
138
+ files.append((os.path.abspath(config.metadata_path), config.metadata_path))
139
+
140
+ data_context().register_payload_callback(metadata_callback)
141
+
121
142
  def host_callback(payload_config: PayloadConfig) -> None:
122
143
  """Add the host files to the payload file list."""
123
144
  config = self
@@ -220,22 +241,9 @@ class TestConfig(EnvironmentConfig):
220
241
  self.junit: bool = getattr(args, 'junit', False)
221
242
  self.failure_ok: bool = getattr(args, 'failure_ok', False)
222
243
 
223
- self.metadata = Metadata.from_file(args.metadata) if args.metadata else Metadata()
224
- self.metadata_path: t.Optional[str] = None
225
-
226
244
  if self.coverage_check:
227
245
  self.coverage = True
228
246
 
229
- def metadata_callback(payload_config: PayloadConfig) -> None:
230
- """Add the metadata file to the payload file list."""
231
- config = self
232
- files = payload_config.files
233
-
234
- if config.metadata_path:
235
- files.append((os.path.abspath(config.metadata_path), config.metadata_path))
236
-
237
- data_context().register_payload_callback(metadata_callback)
238
-
239
247
 
240
248
  class ShellConfig(EnvironmentConfig):
241
249
  """Configuration for the shell command."""
@@ -0,0 +1,166 @@
1
+ """Setup and configure remote debugging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import os
8
+ import re
9
+
10
+ from .util import (
11
+ cache,
12
+ display,
13
+ raw_command,
14
+ ApplicationError,
15
+ )
16
+
17
+ from .processes import (
18
+ Process,
19
+ get_current_process,
20
+ )
21
+
22
+ from .config import (
23
+ EnvironmentConfig,
24
+ )
25
+
26
+ from .metadata import (
27
+ DebuggerSettings,
28
+ DebuggerFlags,
29
+ )
30
+
31
+ from . import (
32
+ data_context,
33
+ CommonConfig,
34
+ )
35
+
36
+
37
+ def initialize_debugger(args: CommonConfig) -> None:
38
+ """Initialize the debugger settings before delegation."""
39
+ if not isinstance(args, EnvironmentConfig):
40
+ return
41
+
42
+ if args.metadata.loaded:
43
+ return # after delegation
44
+
45
+ if collection := data_context().content.collection:
46
+ args.metadata.collection_root = collection.root
47
+
48
+ load_debugger_settings(args)
49
+
50
+
51
+ def parse_debugger_settings(value: str) -> DebuggerSettings:
52
+ """Parse remote debugger settings and apply defaults."""
53
+ try:
54
+ settings = DebuggerSettings(**json.loads(value))
55
+ except Exception as ex:
56
+ raise ApplicationError(f"Invalid debugger settings: {ex}") from ex
57
+
58
+ if not settings.module:
59
+ if not settings.package or 'pydevd-pycharm' in settings.package:
60
+ module = 'pydevd_pycharm'
61
+ else:
62
+ module = 'pydevd'
63
+
64
+ settings = dataclasses.replace(settings, module=module)
65
+
66
+ if settings.package is None:
67
+ if settings.module == 'pydevd_pycharm':
68
+ if pycharm_version := detect_pycharm_version():
69
+ package = f'pydevd-pycharm~={pycharm_version}'
70
+ else:
71
+ package = None
72
+ else:
73
+ package = 'pydevd'
74
+
75
+ settings = dataclasses.replace(settings, package=package)
76
+
77
+ settings.settrace.setdefault('suspend', False)
78
+
79
+ if port := detect_pydevd_port():
80
+ settings = dataclasses.replace(settings, port=port)
81
+
82
+ if detect_pycharm_process():
83
+ # This only works with the default PyCharm debugger.
84
+ # Using it with PyCharm's "Python Debug Server" results in hangs in Ansible workers.
85
+ # Further investigation is required to understand the cause.
86
+ settings = dataclasses.replace(settings, args=settings.args + ['--multiprocess'])
87
+
88
+ return settings
89
+
90
+
91
+ def load_debugger_settings(args: EnvironmentConfig) -> None:
92
+ """Load the remote debugger settings."""
93
+ if args.metadata.debugger_flags.on_demand:
94
+ # On-demand debugging only enables debugging if we're running under a debugger, otherwise it's a no-op.
95
+
96
+ if not detect_pydevd_port():
97
+ display.info('Debugging disabled because no debugger was detected.', verbosity=1)
98
+ args.metadata.debugger_flags = DebuggerFlags.all(False)
99
+ return
100
+
101
+ display.info('Enabling on-demand debugging.', verbosity=1)
102
+
103
+ if not args.metadata.debugger_flags.enable:
104
+ # Assume the user wants all debugging features enabled, since on-demand debugging with no features is pointless.
105
+ args.metadata.debugger_flags = DebuggerFlags.all(True)
106
+
107
+ if not args.metadata.debugger_flags.enable:
108
+ return
109
+
110
+ value = os.environ.get('ANSIBLE_TEST_REMOTE_DEBUGGER') or '{}'
111
+ settings = parse_debugger_settings(value)
112
+
113
+ display.info(f'>>> Debugger Settings\n{json.dumps(dataclasses.asdict(settings), indent=4)}', verbosity=3)
114
+
115
+ args.metadata.debugger_settings = settings
116
+
117
+
118
+ @cache
119
+ def detect_pydevd_port() -> int | None:
120
+ """Return the port for the pydevd instance hosting this process, or `None` if not detected."""
121
+ current_process = get_current_process_cached()
122
+ args = current_process.args
123
+
124
+ if any('/pydevd.py' in arg for arg in args) and (port_idx := args.index('--port')):
125
+ port = int(args[port_idx + 1])
126
+ display.info(f'Detected pydevd debugger port {port}.', verbosity=1)
127
+ return port
128
+
129
+ return None
130
+
131
+
132
+ @cache
133
+ def detect_pycharm_version() -> str | None:
134
+ """Return the version of PyCharm running ansible-test, or `None` if PyCharm was not detected. The result is cached."""
135
+ if pycharm := detect_pycharm_process():
136
+ output = raw_command([pycharm.args[0], '--version'], capture=True)[0]
137
+
138
+ if match := re.search('^Build #PY-(?P<version>[0-9.]+)$', output, flags=re.MULTILINE):
139
+ version = match.group('version')
140
+ display.info(f'Detected PyCharm version {version}.', verbosity=1)
141
+ return version
142
+
143
+ display.warning('Skipping installation of `pydevd-pycharm` since the running PyCharm version could not be detected.')
144
+
145
+ return None
146
+
147
+
148
+ @cache
149
+ def detect_pycharm_process() -> Process | None:
150
+ """Return the PyCharm process running ansible-test, or `None` if PyCharm was not detected. The result is cached."""
151
+ current_process = get_current_process_cached()
152
+ parent = current_process.parent
153
+
154
+ while parent:
155
+ if parent.path.name == 'pycharm':
156
+ return parent
157
+
158
+ parent = parent.parent
159
+
160
+ return None
161
+
162
+
163
+ @cache
164
+ def get_current_process_cached() -> Process:
165
+ """Return the current process. The result is cached."""
166
+ return get_current_process()