File: test_scheduler_cli.py

package info (click to toggle)
cylc-flow 8.6.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 14,368 kB
  • sloc: python: 87,751; sh: 17,109; sql: 233; xml: 171; javascript: 78; lisp: 55; makefile: 11
file content (327 lines) | stat: -rw-r--r-- 9,669 bytes parent folder | download
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from contextlib import contextmanager
from secrets import token_hex
import sqlite3

import pytest

from cylc.flow.exceptions import HostSelectException, ServiceFileError
from cylc.flow.scheduler_cli import (
    RunOptions,
    _distribute,
    _version_check,
)

from .conftest import MonkeyMock


@pytest.fixture
def stopped_workflow_db(tmp_path):
    """Returns a workflow DB with the `cylc_version` set to the provided
    string.

    def test_x(stopped_workflow_db):
        db_file = stopped_workflow_db(version)

    """
    def _stopped_workflow_db(version):
        db_file = tmp_path / 'db'
        conn = sqlite3.connect(db_file)
        conn.execute('''
            CREATE TABLE
                workflow_params(key TEXT, value TEXT, PRIMARY KEY(key))
        ''')
        conn.execute(
            '''
                INSERT INTO
                    workflow_params
                VALUES (?, ?)
            ''',
            ('cylc_version', version)
        )
        conn.commit()
        conn.close()
        return db_file

    return _stopped_workflow_db


@pytest.fixture
def set_cylc_version(monkeypatch):
    """Set the cylc.flow.__version__ attribute.

    def test_x(set_cylc_version):
        set_cylc_version('1.2.3')

    """
    def _set_cylc_version(version):
        monkeypatch.setattr(
            'cylc.flow.scheduler_cli.__version__',
            version,
        )
    return _set_cylc_version


@pytest.fixture
def answer(monkeypatch):
    """Answer a `cylc play` CLI prompt.

    def test_x(answer):
        answer(users_response)

    It also adds an assert on the number of times the prompt interface was
    called. 0 if response is None, else 1.

    """
    @contextmanager
    def _answer(response):
        calls = 0

        def prompt(*args, **kwargs):
            nonlocal calls
            calls += 1
            return response

        monkeypatch.setattr(
            'cylc.flow.scheduler_cli.prompt',
            prompt,
        )

        yield

        expected_calls = 1
        if response is None:
            expected_calls = 0
        assert calls == expected_calls

    return _answer


@pytest.fixture
def interactive(monkeypatch):
    monkeypatch.setattr(
        'cylc.flow.scheduler_cli.is_terminal',
        lambda: True,
    )


@pytest.fixture
def non_interactive(monkeypatch):
    monkeypatch.setattr(
        'cylc.flow.scripts.reinstall.is_terminal',
        lambda: False,
    )


@pytest.mark.parametrize(
    'before, after, downgrade, response, outcome', [
        # no change
        ('8.0.0', '8.0.0', False, None, True),
        # upgrading
        ('8.0rc4.dev', '8.0.0', False, None, True),
        ('8.0.0', '8.0.1', False, None, True),
        ('8.0.0', '8.1.0', False, False, False),
        ('8.0.0', '8.1.0', False, True, True),
        ('8.0.0', '9.0.0', False, False, False),
        ('8.0.0', '9.0.0', False, True, True),
        # downgrading
        ('8.1.1', '8.1.0', False, None, False),
        ('8.1.1', '8.1.0', True, None, True),
        ('8.1.0', '8.0.0', False, None, False),
        ('8.1.0', '8.0.0', True, None, True),
        ('8.1.0', '8.0rc4.dev', True, None, True),
        ('9.1.0', '8.0.0', False, None, False),
        ('9.1.0', '8.0.0', True, None, True),
        # truncated versions
        ('8.1.1', '8', False, None, False),
        ('9.1.1', '8', True, None, True),
    ],
)
def test_version_check_interactive(
    stopped_workflow_db,
    set_cylc_version,
    interactive,
    answer,
    before,
    after,
    response,
    downgrade,
    outcome,
):
    """It should check compatibility with the Cylc version of the prior run.

    When workflows are restarted we need to perform some checks to make sure
    it is safe and sensible to restart with this version of Cylc.

    Pytest Params:
        before:
            The Cylc version the workflow ran with previously.
        after:
            The version of Cylc being used to restart the workflow.
        downgrade:
            The --downgrade option of `cylc play`.
        response:
            The user's response the any CLI prompts.
            If `None` it will assert that no prompts were raised.
        outcome:
            The response of _version_check, True means safe to restart.

    """
    db_file = stopped_workflow_db(before)
    set_cylc_version(after)
    with answer(response):
        assert (
            _version_check(
                db_file, RunOptions(downgrade=downgrade)
            )
            is outcome
        )


def test_version_check_interactive_upgrade(
    stopped_workflow_db,
    set_cylc_version,
    interactive,
    answer,
):
    """If a user interactively upgrades, it should set the upgrade option."""
    db_file = stopped_workflow_db('8.0.0')
    set_cylc_version('8.1.0')
    opts = RunOptions()
    assert opts.upgrade is False
    with answer(True):
        assert _version_check(db_file, opts) is True
    assert opts.upgrade is True


def test_version_check_non_interactive(
    stopped_workflow_db,
    set_cylc_version,
    non_interactive,
):
    """It should not prompt in non-interactive mode.

    * The --upgrade argument should permit upgrade.
    * The --downgrade argument should permit downgrade.
    """
    # upgrade
    db_file = stopped_workflow_db('8.0.0')
    set_cylc_version('8.1.0')
    assert _version_check(db_file, RunOptions()) is False
    assert (
        _version_check(db_file, RunOptions(upgrade=True)) is True
    )  # CLI --upgrade

    # downgrade
    db_file.unlink()
    db_file = stopped_workflow_db('8.1.0')
    set_cylc_version('8.0.0')
    assert _version_check(db_file, RunOptions()) is False
    assert (
        _version_check(db_file, RunOptions(downgrade=True)) is True
    )  # CLI --downgrade


def test_version_check_incompat(tmp_path):
    """It should fail for a corrupted or invalid database file."""
    db_file = tmp_path / 'db'  # invalid DB file
    db_file.touch()
    with pytest.raises(ServiceFileError):
        _version_check(db_file, RunOptions())


def test_version_check_no_db(tmp_path):
    """It should pass if there is no DB file (e.g. on workflow first start)."""
    db_file = tmp_path / 'db'  # non-existent file
    assert _version_check(db_file, RunOptions())


@pytest.mark.parametrize(
    'cli_colour, is_terminal, distribute_colour',
    [
        ('never', True, '--color=never'),
        ('auto', True, '--color=always'),
        ('always', True, '--color=always'),
        ('never', False, '--color=never'),
        ('auto', False, '--color=never'),
        ('always', False, '--color=never'),
    ]
)
def test_distribute_colour(
    monkeymock,
    cli_colour,
    is_terminal,
    distribute_colour,
):
    """It should start detached workflows with the correct --colour option.

    The is_terminal test will fail for detached scheduler processes which means
    that the colour formatting will be stripped for startup. This includes
    the Cylc header logo and any warnings/errors raised during config parsing.

    In order to preserver colour formatting we must set the `--colour` arg to
    `always` when we want the detached process to start in colour mode.

    See https://github.com/cylc/cylc-flow/issues/5159
    """
    _is_terminal = monkeymock('cylc.flow.scheduler_cli.is_terminal')
    _is_terminal.return_value = is_terminal
    _cylc_server_cmd = monkeymock('cylc.flow.scheduler_cli.cylc_server_cmd')
    _cylc_server_cmd.return_value = 0
    opts = RunOptions(host='myhost', color=cli_colour)
    with pytest.raises(SystemExit) as excinfo:
        _distribute('foo', 'foo/run1', opts)
    assert excinfo.value.code == 0
    assert distribute_colour in _cylc_server_cmd.call_args[0][0]


def test_distribute_upgrade(
    monkeymock: MonkeyMock, monkeypatch: pytest.MonkeyPatch
):
    """It should start detached workflows with the --upgrade option if the user
    has interactively chosen to upgrade (typed 'y' at prompt).
    """
    monkeypatch.setattr(
        'sys.argv', ['cylc', 'play', 'foo']  # no upgrade option here
    )
    _cylc_server_cmd = monkeymock('cylc.flow.scheduler_cli.cylc_server_cmd')
    _cylc_server_cmd.return_value = 0
    opts = RunOptions(
        host='myhost',
        upgrade=True,  # added by interactive upgrade
    )
    with pytest.raises(SystemExit) as excinfo:
        _distribute('foo', 'foo/run1', opts)
    assert excinfo.value.code == 0
    assert '--upgrade' in _cylc_server_cmd.call_args[0][0]


def test_distribute_invalid_host(
    mock_glbl_cfg, caplog: pytest.LogCaptureFixture
):
    """It handles a socket error when the host is invalid."""
    mock_glbl_cfg(
        'cylc.flow.host_select.glbl_cfg',
        f'''
            [scheduler]
                [[run hosts]]
                    available = non_exist_{token_hex(4)}
        '''
    )
    with pytest.raises(HostSelectException):
        _distribute('foo', 'foo/run1', RunOptions())