summaryrefslogtreecommitdiffstats
path: root/test/lib/ansible_test/_internal/cli/parsers/value_parsers.py
blob: 9453b76098f75f84c9fe4e30abdfbf769d6fe3fb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""Composite argument value parsers used by other parsers."""
from __future__ import annotations

import collections.abc as c
import typing as t

from ...host_configs import (
    NativePythonConfig,
    PythonConfig,
    VirtualPythonConfig,
)

from ..argparsing.parsers import (
    AbsolutePathParser,
    AnyParser,
    ChoicesParser,
    DocumentationState,
    IntegerParser,
    MatchConditions,
    Parser,
    ParserError,
    ParserState,
    ParserBoundary,
)


class PythonParser(Parser):
    """
    Composite argument parser for Python versions, with support for specifying paths and using virtual environments.

    Allowed formats:

    {version}
    venv/{version}
    venv/system-site-packages/{version}

    The `{version}` has two possible formats:

    X.Y
    X.Y@{path}

    Where `X.Y` is the Python major and minor version number and `{path}` is an absolute path with one of the following formats:

    /path/to/python
    /path/to/python/directory/

    When a trailing slash is present, it is considered a directory, and `python{version}` will be appended to it automatically.

    The default path depends on the context:

    - Known docker/remote environments can declare their own path.
    - The origin host uses `sys.executable` if `{version}` matches the current version in `sys.version_info`.
    - The origin host (as a controller or target) use the `$PATH` environment variable to find `python{version}`.
    - As a fallback/default, the path `/usr/bin/python{version}` is used.

    NOTE: The Python path determines where to find the Python interpreter.
          In the case of an ansible-test managed virtual environment, that Python interpreter will be used to create the virtual environment.
          So the path given will not be the one actually used for the controller or target.

    Known docker/remote environments limit the available Python versions to configured values known to be valid.
    The origin host and unknown environments assume all relevant Python versions are available.
    """
    def __init__(self,
                 versions: c.Sequence[str],
                 *,
                 allow_default: bool,
                 allow_venv: bool,
                 ):
        version_choices = list(versions)

        if allow_default:
            version_choices.append('default')

        first_choices = list(version_choices)

        if allow_venv:
            first_choices.append('venv/')

        venv_choices = list(version_choices) + ['system-site-packages/']

        self.versions = versions
        self.allow_default = allow_default
        self.allow_venv = allow_venv
        self.version_choices = version_choices
        self.first_choices = first_choices
        self.venv_choices = venv_choices
        self.venv_choices = venv_choices

    def parse(self, state: ParserState) -> t.Any:
        """Parse the input from the given state and return the result."""
        boundary: ParserBoundary

        with state.delimit('@/', required=False) as boundary:
            version = ChoicesParser(self.first_choices).parse(state)

        python: PythonConfig

        if version == 'venv':
            with state.delimit('@/', required=False) as boundary:
                version = ChoicesParser(self.venv_choices).parse(state)

            if version == 'system-site-packages':
                system_site_packages = True

                with state.delimit('@', required=False) as boundary:
                    version = ChoicesParser(self.version_choices).parse(state)
            else:
                system_site_packages = False

            python = VirtualPythonConfig(version=version, system_site_packages=system_site_packages)
        else:
            python = NativePythonConfig(version=version)

        if boundary.match == '@':
            # FUTURE: For OriginConfig or ControllerConfig->OriginConfig the path could be validated with an absolute path parser (file or directory).
            python.path = AbsolutePathParser().parse(state)

        return python

    def document(self, state: DocumentationState) -> t.Optional[str]:
        """Generate and return documentation for this parser."""

        docs = '[venv/[system-site-packages/]]' if self.allow_venv else ''

        if self.versions:
            docs += '|'.join(self.version_choices)
        else:
            docs += '{X.Y}'

        docs += '[@{path|dir/}]'

        return docs


class PlatformParser(ChoicesParser):
    """Composite argument parser for "{platform}/{version}" formatted choices."""
    def __init__(self, choices: list[str]) -> None:
        super().__init__(choices, conditions=MatchConditions.CHOICE | MatchConditions.ANY)

    def parse(self, state: ParserState) -> t.Any:
        """Parse the input from the given state and return the result."""
        value = super().parse(state)

        if len(value.split('/')) != 2:
            raise ParserError(f'invalid platform format: {value}')

        return value


class SshConnectionParser(Parser):
    """
    Composite argument parser for connecting to a host using SSH.
    Format: user@host[:port]
    """
    EXPECTED_FORMAT = '{user}@{host}[:{port}]'

    def parse(self, state: ParserState) -> t.Any:
        """Parse the input from the given state and return the result."""
        namespace = state.current_namespace

        with state.delimit('@'):
            user = AnyParser(no_match_message=f'Expected {{user}} from: {self.EXPECTED_FORMAT}').parse(state)

        setattr(namespace, 'user', user)

        with state.delimit(':', required=False) as colon:  # type: ParserBoundary
            host = AnyParser(no_match_message=f'Expected {{host}} from: {self.EXPECTED_FORMAT}').parse(state)

        setattr(namespace, 'host', host)

        if colon.match:
            port = IntegerParser(65535).parse(state)
            setattr(namespace, 'port', port)

        return namespace

    def document(self, state: DocumentationState) -> t.Optional[str]:
        """Generate and return documentation for this parser."""
        return self.EXPECTED_FORMAT