Completed
Push — refactor_travis ( 61762b )
by Dieter
01:33
created

buildtimetrend.travis.TravisData.parse_job_log()   A

Complexity

Conditions 1

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 1
dl 0
loc 8
rs 9.4286
1
# vim: set expandtab sw=4 ts=4:
2
"""
3
Interface to Travis CI API.
4
5
Copyright (C) 2014-2015 Dieter Adriaenssens <[email protected]>
6
7
This file is part of buildtimetrend/python-lib
8
<https://github.com/buildtimetrend/python-lib/>
9
10
This program is free software: you can redistribute it and/or modify
11
it under the terms of the GNU Affero General Public License as published by
12
the Free Software Foundation, either version 3 of the License, or
13
any later version.
14
15
This program is distributed in the hope that it will be useful,
16
but WITHOUT ANY WARRANTY; without even the implied warranty of
17
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
GNU Affero General Public License for more details.
19
20
You should have received a copy of the GNU Affero General Public License
21
along with this program. If not, see <http://www.gnu.org/licenses/>.
22
"""
23
from builtins import str
24
from builtins import object
25
import os
26
import json
27
import re
28
from hashlib import sha256
29
from buildtimetrend import logger
30
from buildtimetrend.tools import check_file
31
from buildtimetrend.tools import check_dict
32
from buildtimetrend.tools import check_num_string
33
from buildtimetrend.tools import is_string
34
from buildtimetrend.tools import get_repo_slug
35
from buildtimetrend.buildjob import BuildJob
36
from buildtimetrend.settings import Settings
37
from buildtimetrend.stages import Stage
38
from buildtimetrend.collection import Collection
39
from buildtimetrend.travis.connector import TravisConnector
40
from buildtimetrend.travis.connector import TravisOrgConnector
41
import buildtimetrend
42
try:
43
    # For Python 3.0 and later
44
    from urllib.error import HTTPError, URLError
45
except ImportError:
46
    # Fall back to Python 2's urllib2
47
    from urllib2 import HTTPError, URLError
48
49
# strings to parse timestamps in Travis CI log file
50
TRAVIS_LOG_PARSE_TIMING_STRINGS = [
51
    r'travis_time:end:(?P<end_hash>.*):start=(?P<start_timestamp>\d+),'
52
    r'finish=(?P<finish_timestamp>\d+),duration=(?P<duration>\d+)\x0d\x1b',
53
    r'travis_fold:end:(?P<end_stage>\w+)\.(?P<end_substage>\d+)\x0d\x1b',
54
    r'travis_fold:start:(?P<start_stage>\w+)\.(?P<start_substage>\d+)\x0d\x1b',
55
    r'travis_time:start:(?P<start_hash>.*)\x0d\x1b\[0K',
56
    r'\$\ (?P<command>.*)\r',
57
]
58
TRAVIS_LOG_PARSE_WORKER_STRING = r'Using worker:\ (?P<hostname>.*):(?P<os>.*)'
59
60
61
def load_travis_env_vars():
62
    """
63
    Load Travis CI environment variables.
64
65
    Load Travis CI environment variables and assign their values to
66
    the corresponding setting value.
67
    """
68
    if "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true":
69
        settings = Settings()
70
71
        # set ci_platform setting to "travis"
72
        settings.add_setting("ci_platform", "travis")
73
74
        # assign TRAVIS environment variable values to setting value
75
        settings.env_var_to_settings("TRAVIS_BUILD_NUMBER", "build")
76
        settings.env_var_to_settings("TRAVIS_JOB_NUMBER", "job")
77
        settings.env_var_to_settings("TRAVIS_BRANCH", "branch")
78
        settings.env_var_to_settings("TRAVIS_REPO_SLUG", "project_name")
79
80
        # convert and set Travis build result
81
        if "TRAVIS_TEST_RESULT" in os.environ:
82
            # map $TRAVIS_TEST_RESULT to a more readable value
83
            settings.add_setting(
84
                "result",
85
                convert_build_result(os.environ["TRAVIS_TEST_RESULT"])
86
            )
87
88
        load_build_matrix_env_vars(settings)
89
        load_travis_pr_env_vars(settings)
90
91
92
def load_build_matrix_env_vars(settings):
93
    """
94
    Retrieve build matrix data from environment variables.
95
96
    Load Travis CI build matrix environment variables
97
    and assign their values to the corresponding setting value.
98
99
    Properties :
100
    - language
101
    - language version (if applicable)
102
    - compiler (if applicable)
103
    - operating system
104
    - environment parameters
105
106
    Parameters:
107
    - settings: Settings instance
108
    """
109
    if "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true":
110
        build_matrix = Collection()
111
112
        if "TRAVIS_OS_NAME" in os.environ:
113
            build_matrix.add_item("os", os.environ["TRAVIS_OS_NAME"])
114
115
        # set language and language version
116
        language_env_vars = {
117
            'TRAVIS_DART_VERSION': 'dart',
118
            'TRAVIS_GO_VERSION': 'go',
119
            'TRAVIS_HAXE_VERSION': 'haxe',
120
            'TRAVIS_JDK_VERSION': 'java',
121
            'TRAVIS_JULIA_VERSION': 'julia',
122
            'TRAVIS_NODE_VERSION': 'javascript',
123
            'TRAVIS_OTP_RELEASE': 'erlang',
124
            'TRAVIS_PERL_VERSION': 'perl',
125
            'TRAVIS_PHP_VERSION': 'php',
126
            'TRAVIS_PYTHON_VERSION': 'python',
127
            'TRAVIS_R_VERSION': 'r',
128
            'TRAVIS_RUBY_VERSION': 'ruby',
129
            'TRAVIS_RUST_VERSION': 'rust',
130
            'TRAVIS_SCALA_VERSION': 'scala'
131
        }
132
        for env_var, language in language_env_vars.items():
133
            if env_var in os.environ:
134
                build_matrix.add_item("language", language)
135
                build_matrix.add_item(
136
                    "language_version",
137
                    str(os.environ[env_var])
138
                )
139
140
        # language specific build matrix parameters
141
        parameters = {
142
            'TRAVIS_XCODE_SDK': 'xcode_sdk',  # Objective-C
143
            'TRAVIS_XCODE_SCHEME': 'xcode_scheme',  # Objective-C
144
            'TRAVIS_XCODE_PROJECT': 'xcode_project',  # Objective-C
145
            'TRAVIS_XCODE_WORKSPACE': 'xcode_workspace',  # Objective-C
146
            'CC': 'compiler',  # C, C++
147
            'ENV': 'parameters'
148
        }
149
150
        for parameter, name in parameters.items():
151
            if parameter in os.environ:
152
                build_matrix.add_item(name, str(os.environ[parameter]))
153
154
        settings.add_setting(
155
            "build_matrix",
156
            build_matrix.get_items_with_summary()
157
        )
158
159
160
def load_travis_pr_env_vars(settings):
161
    """
162
    Load Travis CI pull request environment variables.
163
164
    Load Travis CI pull request environment variables
165
    and assign their values to the corresponding setting value.
166
    """
167
    if "TRAVIS" in os.environ and os.environ["TRAVIS"] == "true" and \
168
            "TRAVIS_PULL_REQUEST" in os.environ and \
169
            not os.environ["TRAVIS_PULL_REQUEST"] == "false":
170
        settings.add_setting("build_trigger", "pull_request")
171
        settings.add_setting(
172
            "pull_request",
173
            {
174
                'is_pull_request': True,
175
                'title': "unknown",
176
                'number': os.environ["TRAVIS_PULL_REQUEST"]
177
            }
178
        )
179
    else:
180
        settings.add_setting("build_trigger", "push")
181
        settings.add_setting(
182
            "pull_request",
183
            {
184
                'is_pull_request': False,
185
                'title': None,
186
                'number': None
187
            }
188
        )
189
190
191
def convert_build_result(result):
192
    """
193
    Convert Travis build result to a more readable value.
194
195
    Parameters:
196
    - result : numerical build result
197
    """
198
    result = check_num_string(result, "result")
199
200
    if result is 0:
201
        build_result = "passed"
202
    elif result is 1:
203
        build_result = "failed"
204
    else:
205
        build_result = "errored"
206
207
    return build_result
208
209
210
def process_notification_payload(payload):
211
    """
212
    Extract repo slug and build number from Travis notification payload.
213
214
    Returns a dictionary with "repo" and "build" information,
215
    or an empty dictionary if the payload could not be processed.
216
217
    Deprecated behaviour : Currently the repo and build information are
218
    also stored in the "settings" object,
219
    but this will be removed in the near future.
220
221
    Parameters:
222
    - payload : Travis CI notification payload
223
    """
224
    settings = Settings()
225
    parameters = {}
226
227
    if payload is None:
228
        logger.warning("Travis notification payload is not set")
229
        return parameters
230
231
    if not is_string(payload):
232
        logger.warning("Travis notification payload is incorrect :"
233
                       " string expected, got %s", type(payload))
234
        return parameters
235
236
    json_payload = json.loads(payload)
237
    logger.info("Travis Payload : %r.", json_payload)
238
239
    # get repo name from payload
240
    if ("repository" in json_payload and
241
            "owner_name" in json_payload["repository"] and
242
            "name" in json_payload["repository"]):
243
244
        repo = get_repo_slug(json_payload["repository"]["owner_name"],
245
                             json_payload["repository"]["name"])
246
247
        logger.info("Build repo : %s", repo)
248
        settings.set_project_name(repo)
249
        parameters["repo"] = repo
250
251
    # get build number from payload
252
    if "number" in json_payload:
253
        logger.info("Build number : %s", str(json_payload["number"]))
254
        settings.add_setting('build', json_payload['number'])
255
        parameters["build"] = json_payload['number']
256
257
    return parameters
258
259
260
def check_authorization(repo, auth_header):
261
    """
262
    Check if Travis CI notification has a correct Authorization header.
263
264
    This check is enabled if travis_account_token is defined in settings.
265
266
    More information on the Authorization header :
267
    http://docs.travis-ci.com/user/notifications/#Authorization-for-Webhooks
268
269
    Returns true if Authorization header is valid, but also if
270
    travis_account_token is not defined.
271
272
    Parameters:
273
    - repo : git repo name
274
    - auth_header : Travis CI notification Authorization header
275
    """
276
    # get Travis account token from Settings
277
    token = Settings().get_setting("travis_account_token")
278
279
    # return True if token is not set
280
    if token is None:
281
        logger.info("Setting travis_account_token is not defined,"
282
                    " Travis CI notification Authorization header"
283
                    " is not checked.")
284
        return True
285
286
    # check if parameters are strings
287
    if is_string(repo) and is_string(auth_header) and is_string(token):
288
        # generate hash (encode string to bytes first)
289
        auth_hash = sha256((repo + token).encode('utf-8')).hexdigest()
290
291
        # compare hash with Authorization header
292
        if auth_hash == auth_header:
293
            logger.info("Travis CI notification Authorization header"
294
                        " is correct.")
295
            return True
296
        else:
297
            logger.error("Travis CI notification Authorization header"
298
                         " is incorrect.")
299
            return False
300
    else:
301
        logger.debug("repo, auth_header and travis_auth_token"
302
                     " should be strings.")
303
        return False
304
305
306
class TravisData(object):
307
308
    """Gather data from Travis CI using the API."""
309
310
    def __init__(self, repo, build_id, connector=None):
311
        """
312
        Retrieve Travis CI build data using the API.
313
314
        Parameters:
315
        - repo : github repository slug (fe. buildtimetrend/python-lib)
316
        - build_id : Travis CI build id (fe. 158)
317
        - connector : Travis Connector instance
318
        """
319
        self.builds_data = {}
320
        self.build_jobs = {}
321
        self.current_build_data = {}
322
        self.current_job = BuildJob()
323
        self.travis_substage = None
324
        self.repo = repo
325
        self.build_id = str(build_id)
326
        # set TravisConnector if it is defined
327
        if connector is not None and type(connector) is TravisConnector:
328
            self.connector = connector
329
        # use Travis Org connector by default
330
        else:
331
            self.connector = TravisOrgConnector()
332
333
    def get_build_data(self):
334
        """
335
        Retrieve Travis CI build data.
336
337
        Returns true if retrieving data was succesful, false on error.
338
        """
339
        request = 'repos/{repo}/builds?number={build_id}'.format(
340
            repo=self.repo, build_id=self.build_id
341
        )
342
        try:
343
            self.builds_data = self.connector.json_request(request)
344
        except (HTTPError, URLError) as msg:
345
            logger.error("Error getting build data from Travis CI: %s", msg)
346
            return False
347
348
        # log builds_data
349
        logger.debug(
350
            "Build #%s data : %s",
351
            str(self.build_id),
352
            json.dumps(self.builds_data, sort_keys=True, indent=2)
353
        )
354
355
        return True
356
357
    def get_substage_name(self, command):
358
        """
359
        Resolve Travis CI substage name that corresponds to a cli command.
360
361
        Parameters:
362
        - command : cli command
363
        """
364
        if not is_string(command):
365
            return ""
366
367
        if len(self.current_build_data) > 0 and \
368
                "config" in self.current_build_data:
369
            build_config = self.current_build_data["config"]
370
        else:
371
            logger.warning(
372
                "Travis CI build config is not set"
373
            )
374
            return ""
375
376
        # check if build_config collection is empty
377
        if build_config:
378
            for stage_name, commands in build_config.items():
379
                if type(commands) is list and command in commands:
380
                    substage_number = commands.index(command) + 1
381
                    substage_name = "{stage}.{substage:d}".format(
382
                        stage=stage_name, substage=substage_number
383
                    )
384
                    logger.debug(
385
                        "Substage %s corresponds to '%s'",
386
                        substage_name, command
387
                    )
388
                    return substage_name
389
390
        return ""
391
392
    def process_build_jobs(self):
393
        """
394
        Retrieve Travis CI build job data.
395
396
        Method is a generator, iterate result to get each processed build job.
397
        """
398
        if len(self.builds_data) > 0 and "builds" in self.builds_data:
399
            for build in self.builds_data['builds']:
400
                self.current_build_data = build
401
402
                if "job_ids" in build:
403
                    for job_id in build['job_ids']:
404
                        yield self.process_build_job(job_id)
405
406
            # reset current_build_data after builds are processed
407
            self.current_build_data = {}
408
409
    def process_build_job(self, job_id):
410
        """
411
        Retrieve Travis CI build job data.
412
413
        Parameters:
414
        - job_id : ID of the job to process
415
        """
416
        if job_id is None:
417
            return None
418
419
        # retrieve job data from Travis CI
420
        job_data = self.get_job_data(job_id)
421
        # process build/job data
422
        self.process_job_data(job_data)
423
        # parse Travis CI job log file
424
        self.parse_job_log(job_id)
425
426
        # store build job
427
        self.build_jobs[str(job_id)] = self.current_job
428
        # create new build job instance
429
        self.current_job = BuildJob()
430
431
        # return processed build job
432
        return self.build_jobs[str(job_id)]
433
434
    def get_job_data(self, job_id):
435
        """
436
        Retrieve Travis CI job data.
437
438
        Parameters:
439
        - job_id : ID of the job to process
440
        """
441
        request = 'jobs/{:s}'.format(str(job_id))
442
        job_data = self.connector.json_request(request)
443
444
        # log job_data
445
        logger.debug(
446
            "Job #%s data : %s",
447
            str(job_id),
448
            json.dumps(job_data, sort_keys=True, indent=2)
449
        )
450
451
        return job_data
452
453
    def process_job_data(self, job_data):
454
        """
455
        Process Job/build data.
456
457
        Set build/job properties :
458
        - Build/job ID
459
        - build result : passed, failed, errored
460
        - git repo
461
        - git branch
462
        - CI platform : Travis
463
        - build matrix (language, language version, compiler, ...)
464
        - build_trigger : push, pull_request
465
        - pull_request (is_pull_request, title, number)
466
467
        Parameters:
468
        - job_data : dictionary with Travis CI job data
469
        """
470
        self.current_job.add_property(
471
            "build",
472
            # buildnumber is part before "." of job number
473
            job_data['job']['number'].split(".")[0]
474
        )
475
        self.current_job.add_property("job", job_data['job']['number'])
476
        self.current_job.add_property("branch", job_data['commit']['branch'])
477
        self.current_job.add_property(
478
            "repo",
479
            job_data['job']['repository_slug']
480
        )
481
        self.current_job.add_property("ci_platform", 'travis')
482
        self.current_job.add_property("result", job_data['job']['state'])
483
484
        self.set_build_matrix(job_data)
485
486
        self.process_pull_request_data()
487
488
        self.current_job.set_started_at(job_data['job']['started_at'])
489
        self.current_job.set_finished_at(job_data['job']['finished_at'])
490
491
        # calculate job duration from start and finished timestamps
492
        # if no timing tags are available
493
        if not self.has_timing_tags():
494
            self.current_job.add_property("duration", self.get_job_duration())
495
496
    def set_build_matrix(self, job_data):
497
        """
498
        Retrieve build matrix data from job data and store in properties.
499
500
        Properties :
501
        - language
502
        - language version (if applicable)
503
        - compiler (if applicable)
504
        - operating system
505
        - environment parameters
506
507
        Parameters:
508
        - job_data : dictionary with Travis CI job data
509
        """
510
        build_matrix = Collection()
511
512
        job_config = job_data['job']['config']
513
514
        language = job_config['language']
515
        build_matrix.add_item("language", language)
516
517
        # set language version
518
        # ('d', 'dart', 'go', 'perl', 'php', 'python', 'rust')
519
        if language in job_config:
520
            if language == "android":
521
                build_matrix.add_item(
522
                    "language_components",
523
                    " ".join(job_config[language]["components"])
524
                )
525
            else:
526
                build_matrix.add_item(
527
                    "language_version",
528
                    str(job_config[language])
529
                )
530
531
        # language specific build matrix parameters
532
        parameters = {
533
            'ghc': 'ghc',  # Haskell
534
            'jdk': 'jdk',  # Java, Android, Groovy, Ruby, Scala
535
            'lein': 'lein',  # Clojure
536
            'mono': 'mono',  # C#, F#, Visual Basic
537
            'node_js': 'node_js',  # Javascript
538
            'otp_release': 'otp_release',  # Erlang
539
            'rvm': 'rvm',  # Ruby, Objective-C
540
            'gemfile': 'gemfile',  # Ruby, Objective-C
541
            'xcode_sdk': 'xcode_sdk',  # Objective-C
542
            'xcode_scheme': 'xcode_scheme',  # Objective-C
543
            'compiler': 'compiler',  # C, C++
544
            'os': 'os',
545
            'env': 'parameters'
546
        }
547
        for parameter, name in parameters.items():
548
            if parameter in job_config:
549
                build_matrix.add_item(name, str(job_config[parameter]))
550
551
        self.current_job.add_property(
552
            "build_matrix",
553
            build_matrix.get_items_with_summary()
554
        )
555
556
    def process_pull_request_data(self):
557
        """Retrieve pull request data from Travis CI API."""
558
        # check if collection is empty
559
        if self.current_build_data:
560
            if "event_type" in self.current_build_data:
561
                # build trigger (push or pull_request)
562
                self.current_job.add_property(
563
                    "build_trigger",
564
                    self.current_build_data["event_type"]
565
                )
566
567
            # pull_request
568
            pull_request_data = {}
569
            if "pull_request" in self.current_build_data:
570
                pull_request_data["is_pull_request"] = \
571
                    self.current_build_data["pull_request"]
572
            else:
573
                pull_request_data["is_pull_request"] = False
574
575
            if "pull_request_title" in self.current_build_data:
576
                pull_request_data["title"] = \
577
                    self.current_build_data["pull_request_title"]
578
579
            if "pull_request_number" in self.current_build_data:
580
                pull_request_data["number"] = \
581
                    self.current_build_data["pull_request_number"]
582
583
            self.current_job.add_property("pull_request", pull_request_data)
584
585
    def parse_job_log(self, job_id):
586
        """
587
        Parse Travis CI job log.
588
589
        Parameters:
590
        - job_id : ID of the job to process
591
        """
592
        self.parse_job_log_stream(self.connector.download_job_log(job_id))
593
594
    def parse_job_log_file(self, filename):
595
        """
596
        Open a Travis CI log file and parse it.
597
598
        Parameters :
599
        - filename : filename of Travis CI log
600
        Returns false if file doesn't exist, true if it was read successfully.
601
        """
602
        # load timestamps file
603
        if not check_file(filename):
604
            return False
605
606
        # read timestamps, calculate stage duration
607
        with open(filename, 'rb') as file_stream:
608
            self.parse_job_log_stream(file_stream)
609
610
        return True
611
612
    def parse_job_log_stream(self, stream):
613
        """
614
        Parse Travis CI job log stream.
615
616
        Parameters:
617
        - stream : stream of job log file
618
        """
619
        self.travis_substage = TravisSubstage()
620
        check_timing_tags = self.has_timing_tags()
621
622
        for line in stream:
623
            # convert to str if line is bytes type
624
            if isinstance(line, bytes):
625
                line = line.decode('utf-8')
626
            # parse Travis CI timing tags
627
            if check_timing_tags and 'travis_' in line:
628
                self.parse_travis_time_tag(line)
629
            # parse Travis CI worker tag
630
            if 'Using worker:' in line:
631
                self.parse_travis_worker_tag(line)
632
633
    def parse_travis_time_tag(self, line):
634
        """
635
        Parse and process Travis CI timing tags.
636
637
        Parameters:
638
        - line : line from logfile containing Travis CI tags
639
        """
640
        if self.travis_substage is None:
641
            self.travis_substage = TravisSubstage()
642
643
        escaped_line = line.replace('\x0d', '*').replace('\x1b', 'ESC')
644
        logger.debug('line : %s', escaped_line)
645
646
        # parse Travis CI timing tags
647
        for parse_string in TRAVIS_LOG_PARSE_TIMING_STRINGS:
648
            result = re.search(parse_string, line)
649
            if result:
650
                self.travis_substage.process_parsed_tags(result.groupdict())
651
652
                # when finished : log stage and create a new instance
653
                if self.travis_substage.has_finished():
654
                    # set substage name, if it is not set
655
                    if not self.travis_substage.has_name() and \
656
                            self.travis_substage.has_command():
657
                        self.travis_substage.set_name(
658
                            self.get_substage_name(
659
                                self.travis_substage.get_command()
660
                            )
661
                        )
662
663
                    # only log complete substages
664
                    if not self.travis_substage.finished_incomplete:
665
                        self.current_job.add_stage(self.travis_substage.stage)
666
                    self.travis_substage = TravisSubstage()
667
668
    def parse_travis_worker_tag(self, line):
669
        """
670
        Parse and process Travis CI worker tag.
671
672
        Parameters:
673
        - line : line from logfile containing Travis CI tags
674
        """
675
        logger.debug('line : %s', line)
676
677
        # parse Travis CI worker tags
678
        result = re.search(TRAVIS_LOG_PARSE_WORKER_STRING, line)
679
        if not result:
680
            return
681
682
        worker_tags = result.groupdict()
683
684
        # check if parameter worker_tags is a dictionary and
685
        # if it contains all required tags
686
        tag_list = list({'hostname', 'os'})
687
        if check_dict(worker_tags, "worker_tags", tag_list):
688
            logger.debug("Worker tags : %s", worker_tags)
689
            self.current_job.add_property("worker", worker_tags)
690
691
    def has_timing_tags(self):
692
        """
693
        Check if Travis CI job log has timing tags.
694
695
        Timing tags were introduced on Travis CI starting 2014-08-07,
696
        check if started_at is more recent.
697
        """
698
        started_at = self.current_job.get_property("started_at")
699
        if started_at is None or "timestamp_seconds" not in started_at:
700
            return False
701
702
        # 1407369600 is epoch timestamp of 2014-08-07T00:00:00Z
703
        return started_at["timestamp_seconds"] > 1407369600
704
705
    def get_job_duration(self):
706
        """Calculate build job duration."""
707
        started_at = self.current_job.get_property("started_at")
708
        finished_at = self.current_job.get_property("finished_at")
709
        if started_at is None or "timestamp_seconds" not in started_at or \
710
                finished_at is None or "timestamp_seconds" not in finished_at:
711
            return 0.0
712
713
        timestamp_start = float(started_at["timestamp_seconds"])
714
        timestamp_end = float(finished_at["timestamp_seconds"])
715
        return timestamp_end - timestamp_start
716
717
    def get_started_at(self):
718
        """Retrieve timestamp when build was started."""
719
        if check_dict(self.current_build_data, key_list=["started_at"]):
720
            return self.current_build_data['started_at']
721
        else:
722
            return None
723
724
    def get_finished_at(self):
725
        """Retrieve timestamp when build finished."""
726
        if check_dict(self.current_build_data, key_list=["finished_at"]):
727
            return self.current_build_data['finished_at']
728
        else:
729
            return None
730
731
732
class TravisSubstage(object):
733
734
    """
735
    Travis CI substage object.
736
737
    It is constructed by feeding parsed tags from Travis CI logfile.
738
    """
739
740
    def __init__(self):
741
        """Initialise Travis CI Substage object."""
742
        self.stage = Stage()
743
        self.timing_hash = ""
744
        self.finished_incomplete = False
745
        self.finished = False
746
747
    def process_parsed_tags(self, tags_dict):
748
        """
749
        Process parsed tags and calls the corresponding handler method.
750
751
        Parameters:
752
        - tags_dict : dictionary with parsed tags
753
        """
754
        result = False
755
756
        # check if parameter tags_dict is a dictionary
757
        if check_dict(tags_dict, "tags_dict"):
758
            if 'start_stage' in tags_dict:
759
                result = self.process_start_stage(tags_dict)
760
            elif 'start_hash' in tags_dict:
761
                result = self.process_start_time(tags_dict)
762
            elif 'command' in tags_dict:
763
                result = self.process_command(tags_dict)
764
            elif 'end_hash' in tags_dict:
765
                result = self.process_end_time(tags_dict)
766
            elif 'end_stage' in tags_dict:
767
                result = self.process_end_stage(tags_dict)
768
769
        return result
770
771
    def process_start_stage(self, tags_dict):
772
        """
773
        Process parsed start_stage tags.
774
775
        Parameters:
776
        - tags_dict : dictionary with parsed tags
777
        """
778
        # check if parameter tags_dict is a dictionary and
779
        # if it contains all required tags
780
        tag_list = list({'start_stage', 'start_substage'})
781
        if not check_dict(tags_dict, "tags_dict", tag_list):
782
            return False
783
784
        logger.debug("Start stage : %s", tags_dict)
785
786
        result = False
787
788
        if self.has_started():
789
            logger.info("Substage already started")
790
        else:
791
            name = "{stage:s}.{substage:s}".format(
792
                stage=tags_dict['start_stage'],
793
                substage=tags_dict['start_substage']
794
            )
795
            result = self.set_name(name)
796
797
        return result
798
799
    def process_start_time(self, tags_dict):
800
        """
801
        Process parsed start_time tags.
802
803
        Parameters:
804
        - tags_dict : dictionary with parsed tags
805
        """
806
        # check if parameter tags_dict is a dictionary and
807
        # if it contains all required tags
808
        if not check_dict(tags_dict, "tags_dict", 'start_hash'):
809
            return False
810
811
        logger.debug("Start time : %s", tags_dict)
812
813
        if self.has_timing_hash():
814
            logger.info("Substage timing already set")
815
            return False
816
817
        self.timing_hash = tags_dict['start_hash']
818
        logger.info("Set timing hash : %s", self.timing_hash)
819
820
        return True
821
822
    def process_command(self, tags_dict):
823
        """
824
        Process parsed command tag.
825
826
        Parameters:
827
        - tags_dict : dictionary with parsed tags
828
        """
829
        # check if parameter tags_dict is a dictionary and
830
        # if it contains all required tags
831
        if not check_dict(tags_dict, "tags_dict", 'command'):
832
            return False
833
834
        logger.debug("Command : %s", tags_dict)
835
836
        result = False
837
838
        if self.has_command():
839
            logger.info("Command is already set")
840
        elif self.stage.set_command(tags_dict['command']):
841
            logger.info("Set command : %s", tags_dict['command'])
842
            result = True
843
844
        return result
845
846
    def process_end_time(self, tags_dict):
847
        """
848
        Process parsed end_time tags.
849
850
        Parameters:
851
        - tags_dict : dictionary with parsed tags
852
        """
853
        # check if parameter tags_dict is a dictionary and
854
        # if it contains all required tags
855
        tag_list = list({
856
            'end_hash',
857
            'start_timestamp',
858
            'finish_timestamp',
859
            'duration'
860
        })
861
        if not check_dict(tags_dict, "tags_dict", tag_list):
862
            return False
863
864
        logger.debug("End time : %s", tags_dict)
865
866
        result = False
867
868
        # check if timing was started
869
        # and if hash matches
870
        if (not self.has_timing_hash() or
871
                self.timing_hash != tags_dict['end_hash']):
872
            logger.info("Substage timing was not started or"
873
                        " hash doesn't match")
874
            self.finished_incomplete = True
875
        else:
876
            set_started = set_finished = set_duration = False
877
878
            # Set started timestamp
879
            if self.stage.set_started_at_nano(tags_dict['start_timestamp']):
880
                logger.info("Stage started at %s",
881
                            self.stage.data["started_at"]["isotimestamp"])
882
                set_started = True
883
884
            # Set finished timestamp
885
            if self.stage.set_finished_at_nano(tags_dict['finish_timestamp']):
886
                logger.info("Stage finished at %s",
887
                            self.stage.data["finished_at"]["isotimestamp"])
888
                set_finished = True
889
890
            # Set duration
891
            if self.stage.set_duration_nano(tags_dict['duration']):
892
                logger.info("Stage duration : %ss",
893
                            self.stage.data['duration'])
894
                set_duration = True
895
896
            result = set_started and set_finished and set_duration
897
898
        return result
899
900
    def process_end_stage(self, tags_dict):
901
        """
902
        Process parsed end_stage tags.
903
904
        Parameters:
905
        - tags_dict : dictionary with parsed tags
906
        """
907
        # check if parameter tags_dict is a dictionary and
908
        # if it contains all required tags
909
        tag_list = list({'end_stage', 'end_substage'})
910
        if not check_dict(tags_dict, "tags_dict", tag_list):
911
            return False
912
913
        logger.debug("End stage : %s", tags_dict)
914
915
        # construct substage name
916
        end_stagename = "{stage}.{substage}".format(
917
            stage=tags_dict['end_stage'],
918
            substage=tags_dict['end_substage']
919
        )
920
921
        # check if stage was started
922
        # and if substage name matches
923
        if not self.has_name() or self.stage.data["name"] != end_stagename:
924
            logger.info("Substage was not started or name doesn't match")
925
            self.finished_incomplete = True
926
            return False
927
928
        # stage finished successfully
929
        self.finished = True
930
        logger.info("Stage %s finished successfully", self.get_name())
931
932
        return True
933
934
    def get_name(self):
935
        """
936
        Return substage name.
937
938
        If name is not set, return the command.
939
        """
940
        if self.has_name():
941
            return self.stage.data["name"]
942
        elif self.has_command():
943
            return self.stage.data["command"]
944
        else:
945
            return ""
946
947
    def set_name(self, name):
948
        """
949
        Set substage name.
950
951
        Parameters:
952
        - name : substage name
953
        """
954
        return self.stage.set_name(name)
955
956
    def has_name(self):
957
        """
958
        Check if substage has a name.
959
960
        Returns true if substage has a name
961
        """
962
        return "name" in self.stage.data and \
963
            self.stage.data["name"] is not None and \
964
            len(self.stage.data["name"]) > 0
965
966
    def has_timing_hash(self):
967
        """
968
        Check if substage has a timing hash.
969
970
        Returns true if substage has a timing hash
971
        """
972
        return self.timing_hash is not None and len(self.timing_hash) > 0
973
974
    def has_command(self):
975
        """
976
        Check if a command is set for substage.
977
978
        Returns true if a command is set
979
        """
980
        return "command" in self.stage.data and \
981
            self.stage.data["command"] is not None and \
982
            len(self.stage.data["command"]) > 0
983
984
    def get_command(self):
985
        """Return substage command."""
986
        if self.has_command():
987
            return self.stage.data["command"]
988
        else:
989
            return ""
990
991
    def has_started(self):
992
        """
993
        Check if substage has started.
994
995
        Returns true if substage has started
996
        """
997
        return self.has_name() or self.has_timing_hash() or self.has_command()
998
999
    def has_finished(self):
1000
        """
1001
        Check if substage has finished.
1002
1003
        A substage is finished, if either the finished_timestamp is set,
1004
        or if finished is (because of an error in parsing the tags).
1005
1006
        Returns true if substage has finished
1007
        """
1008
        return self.finished_incomplete or \
1009
            self.has_name() and self.finished or \
1010
            not self.has_name() and self.has_timing_hash() and \
1011
            "finished_at" in self.stage.data or \
1012
            not self.has_name() and not self.has_timing_hash() and \
1013
            self.has_command()
1014