Completed
Push — master ( 87e5f5...efa853 )
by Dieter
01:09
created

buildtimetrend.add_events()   A

Complexity

Conditions 1

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 1
dl 0
loc 15
rs 9.4285
1
# vim: set expandtab sw=4 ts=4:
2
"""
3
Interface to Keen IO.
4
5
Copyright (C) 2014-2016 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
24
from __future__ import division
25
from builtins import str
26
import os
27
import copy
28
import keen
29
import math
30
from datetime import datetime
31
import requests
32
from buildtimetrend import logger
33
from keen import scoped_keys
34
from buildtimetrend.settings import Settings
35
from buildtimetrend.tools import check_dict
36
from buildtimetrend.tools import is_list
37
from buildtimetrend.tools import is_string
38
from buildtimetrend.buildjob import BuildJob
39
40
41
TIME_INTERVALS = {
42
    'week': {'name': 'week', 'timeframe': 'this_7_days', 'max_age': 600},
43
    'month': {'name': 'month', 'timeframe': 'this_30_days', 'max_age': 600},
44
    'year': {'name': 'year', 'timeframe': 'this_52_weeks', 'max_age': 1800}
45
}
46
KEEN_PROJECT_INFO_NAME = "buildtime_trend"
47
48
49
def has_project_id():
50
    """Check if Keen.io project ID is set."""
51
    if "KEEN_PROJECT_ID" in os.environ or keen.project_id is not None:
52
        return True
53
54
    logger.warning("Keen.io Project ID is not set")
55
    return False
56
57
58
def has_master_key():
59
    """Check if Keen.io Master API key is set."""
60
    if "KEEN_MASTER_KEY" in os.environ or keen.master_key is not None:
61
        return True
62
63
    logger.warning("Keen.io Master API Key is not set")
64
    return False
65
66
67
def has_write_key():
68
    """Check if Keen.io Write Key is set."""
69
    if "KEEN_WRITE_KEY" in os.environ or keen.write_key is not None:
70
        return True
71
72
    logger.warning("Keen.io Write Key is not set")
73
    return False
74
75
76
def has_read_key():
77
    """Check if Keen.io Read key is set."""
78
    if "KEEN_READ_KEY" in os.environ or keen.read_key is not None:
79
        return True
80
81
    logger.warning("Keen.io Read Key is not set")
82
    return False
83
84
85
def is_writable():
86
    """Check if login keys for Keen IO API are set, to allow writing."""
87
    if has_project_id() and has_write_key():
88
        return True
89
90
    logger.warning("Keen.io Write Key is not set")
91
    return False
92
93
94
def is_readable():
95
    """Check if login keys for Keen IO API are set, to allow reading."""
96
    if has_project_id() and has_read_key():
97
        return True
98
99
    logger.warning("Keen.io Read Key is not set")
100
    return False
101
102
103
def generate_read_key(repo):
104
    """
105
    Create scoped key for reading only the build-stages related data.
106
107
    Param repo : github repository slug (fe. buildtimetrend/python-lib)
108
    """
109
    if not has_master_key():
110
        logger.warning("Keen.io Read Key was not created,"
111
                       " keen.master_key is not defined.")
112
        return None
113
114
    master_key = keen.master_key or os.environ.get("KEEN_MASTER_KEY")
115
116
    privileges = {
117
        "allowed_operations": ["read"]
118
    }
119
120
    if repo is not None:
121
        privileges["filters"] = [get_repo_filter(repo)]
122
123
    logger.info("Keen.io Read Key is created for %s", repo)
124
    return scoped_keys.encrypt(master_key, privileges)
125
126
127
def generate_write_key():
128
    """Create scoped key for write access to Keen.io database."""
129
    if not has_master_key():
130
        logger.warning("Keen.io Write Key was not created,"
131
                       " keen.master_key is not defined.")
132
        return None
133
134
    master_key = keen.master_key or os.environ.get("KEEN_MASTER_KEY")
135
136
    privileges = {
137
        "allowed_operations": ["write"]
138
    }
139
140
    logger.info("Keen.io Write Key is created")
141
    return scoped_keys.encrypt(master_key, privileges)
142
143
144
def send_build_data(buildjob, detail=None):
145
    """
146
    Send build data generated by client to keen.io.
147
148
    Parameters:
149
    - buildjob : BuildJob instance
150
    - detail : Data storage detail level :
151
               'minimal', 'basic', 'full', 'extended'
152
    """
153
    if not isinstance(buildjob, BuildJob):
154
        raise TypeError("param buildjob should be a BuildJob instance")
155
156
    data_detail = Settings().get_value_or_setting("data_detail", detail)
157
158
    if is_writable():
159
        logger.info(
160
            "Sending client build job data to Keen.io (data detail: %s)",
161
            data_detail
162
        )
163
        # store build job data
164
        add_event("build_jobs", {"job": buildjob.to_dict()})
165
166
        # store build stages
167
        if data_detail in ("full", "extended"):
168
            add_events("build_stages", buildjob.stages_to_list())
169
170
171
def send_build_data_service(buildjob, detail=None):
172
    """
173
    Send build data generated by service to keen.io.
174
175
    Parameters:
176
    - buildjob : BuildJob instance
177
    - detail : Data storage detail level :
178
               'minimal', 'basic', 'full', 'extended'
179
    """
180
    if not isinstance(buildjob, BuildJob):
181
        raise TypeError("param buildjob should be a BuildJob instance")
182
183
    data_detail = Settings().get_value_or_setting("data_detail", detail)
184
185
    if is_writable():
186
        logger.info(
187
            "Sending service build job data to Keen.io (data detail: %s)",
188
            data_detail
189
        )
190
        add_event("build_jobs", {"job": buildjob.to_dict()})
191
        if data_detail in ("full", "extended"):
192
            add_events("build_substages", buildjob.stages_to_list())
193
194
195
def add_event(event_collection, payload):
196
    """
197
    Wrapper for keen.add_event(), adds project info.
198
199
    Param event_collection : collection event data is submitted to
200
    Param payload : data that is submitted
201
    """
202
    # add project info to this event
203
    payload = add_project_info_dict(payload)
204
205
    # submit list of events to Keen.io
206
    keen.add_event(event_collection, payload)
207
    logger.info(
208
        "Sent single event to '%s' collection (Keen.io)",
209
        event_collection
210
    )
211
212
213
def add_events(event_collection, payload):
214
    """
215
    Wrapper for keen.add_events(), adds project info to each event.
216
217
    Param event_collection : collection event data is submitted to
218
    Param payload : array of events that is submitted
219
    """
220
    # add project info to each event
221
    payload = add_project_info_list(payload)
222
223
    # submit list of events to Keen.io
224
    keen.add_events({event_collection: payload})
225
    logger.info(
226
        "Sent multiple events to '%s' collection (Keen.io)",
227
        event_collection
228
    )
229
230
231
def add_project_info_dict(payload):
232
    """
233
    Add project info to a dictonary.
234
235
    Param payload: dictonary payload
236
    """
237
    # check if payload is a dictionary, throws an exception if it isn't
238
    check_dict(payload, "payload")
239
240
    payload_as_dict = copy.deepcopy(payload)
241
242
    payload_as_dict[KEEN_PROJECT_INFO_NAME] = Settings().get_project_info()
243
244
    if "job" in payload:
245
        # override project_name, set to build_job repo
246
        if "repo" in payload["job"]:
247
            payload_as_dict[KEEN_PROJECT_INFO_NAME]["project_name"] = \
248
                payload["job"]["repo"]
249
250
        # override timestamp, set to finished_at timestamp
251
        if "finished_at" in payload["job"]:
252
            payload_as_dict["keen"] = {
253
                "timestamp": payload["job"]["finished_at"]["isotimestamp"]
254
            }
255
256
    return payload_as_dict
257
258
259
def add_project_info_list(payload):
260
    """
261
    Add project info to a list of dictionaries.
262
263
    Param payload: list of dictionaries
264
    """
265
    # check if payload is a list, throws an exception if it isn't
266
    is_list(payload, "payload")
267
268
    payload_as_list = []
269
270
    # loop over dicts in payload and add project info to each one
271
    for event_dict in payload:
272
        payload_as_list.append(add_project_info_dict(event_dict))
273
274
    return payload_as_list
275
276
277
def get_dashboard_keen_config(repo):
278
    """
279
    Generate the Keen.io settings for the configuration of the dashboard.
280
281
    The dashboard is Javascript powered HTML file that contains the
282
    graphs generated by Keen.io.
283
    """
284
    # initialise config settings
285
    keen_config = {}
286
287
    if not has_project_id() or not has_master_key():
288
        logger.warning("Keen.io related config settings could not be created,"
289
                       " keen.project_id and/or keen.master_key"
290
                       " are not defined.")
291
        return keen_config
292
293
    # set keen project ID
294
    if keen.project_id is None:
295
        keen_project_id = os.environ["KEEN_PROJECT_ID"]
296
    else:
297
        keen_project_id = keen.project_id
298
    keen_config['projectId'] = str(keen_project_id)
299
300
    # generate read key
301
    read_key = generate_read_key(repo)
302
    if read_key is not None:
303
        # convert bytes to string
304
        if isinstance(read_key, bytes):
305
            read_key = read_key.decode('utf-8')
306
307
        keen_config['readKey'] = str(read_key)
308
309
    # return keen config settings
310
    return keen_config
311
312
313
def get_avg_buildtime(repo=None, interval=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
314
    """
315
    Query Keen.io database and retrieve average build time.
316
317
    Parameters :
318
    - repo : repo name (fe. buildtimetrend/service)
319
    - interval : timeframe, possible values : 'week', 'month', 'year',
320
                 anything else defaults to 'week'
321
    """
322
    if repo is None or not is_readable():
323
        return -1
324
325
    interval_data = check_time_interval(interval)
326
327
    try:
328
        return keen.average(
329
            "build_jobs",
330
            target_property="job.duration",
331
            timeframe=interval_data['timeframe'],
332
            max_age=interval_data['max_age'],
333
            filters=[get_repo_filter(repo)]
334
        )
335
    except requests.ConnectionError:
336
        logger.error("Connection to Keen.io API failed")
337
        return -1
338
    except keen.exceptions.KeenApiError as msg:
339
        logger.error("Error in keenio.get_avg_buildtime() : " + str(msg))
340
        return -1
341
342
343
def get_total_build_jobs(repo=None, interval=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
344
    """
345
    Query Keen.io database and retrieve total number of build jobs.
346
347
    Parameters :
348
    - repo : repo name (fe. buildtimetrend/service)
349
    - interval : timeframe, possible values : 'week', 'month', 'year',
350
                 anything else defaults to 'week'
351
    """
352
    if repo is None or not is_readable():
353
        return -1
354
355
    interval_data = check_time_interval(interval)
356
357
    try:
358
        return keen.count_unique(
359
            "build_jobs",
360
            target_property="job.job",
361
            timeframe=interval_data['timeframe'],
362
            max_age=interval_data['max_age'],
363
            filters=[get_repo_filter(repo)]
364
        )
365
    except requests.ConnectionError:
366
        logger.error("Connection to Keen.io API failed")
367
        return -1
368
    except keen.exceptions.KeenApiError as msg:
369
        logger.error("Error in keenio.get_total_build_jobs() : " + str(msg))
370
        return -1
371
372
373
def get_passed_build_jobs(repo=None, interval=None):
374
    """
375
    Query Keen.io database and retrieve total number of build jobs that passed.
376
377
    Parameters :
378
    - repo : repo name (fe. buildtimetrend/service)
379
    - interval : timeframe, possible values : 'week', 'month', 'year',
380
                 anything else defaults to 'week'
381
    """
382
    if repo is None or not is_readable():
383
        return -1
384
385
    interval_data = check_time_interval(interval)
386
387
    try:
388
        return keen.count_unique(
389
            "build_jobs",
390
            target_property="job.job",
391
            timeframe=interval_data['timeframe'],
392
            max_age=interval_data['max_age'],
393
            filters=[
394
                get_repo_filter(repo),
395
                {
396
                    "property_name": "job.result",
397
                    "operator": "eq",
398
                    "property_value": "passed"
399
                }
400
            ]
401
        )
402
    except requests.ConnectionError:
403
        logger.error("Connection to Keen.io API failed")
404
        return -1
405
    except keen.exceptions.KeenApiError as msg:
406
        logger.error("Error in keenio.get_passed_build_jobs() : " + str(msg))
407
        return -1
408
409
410
def get_pct_passed_build_jobs(repo=None, interval=None):
411
    """
412
    Calculate percentage of passed build jobs.
413
414
    Parameters :
415
    - repo : repo name (fe. buildtimetrend/service)
416
    - interval : timeframe, possible values : 'week', 'month', 'year',
417
                 anything else defaults to 'week'
418
    """
419
    total_jobs = get_total_build_jobs(repo, interval)
420
    passed_jobs = get_passed_build_jobs(repo, interval)
421
422
    logger.debug("passed/total build jobs : %d/%d", passed_jobs, total_jobs)
423
424
    # calculate percentage if at least one job was executed
425
    # passed is a valid number (not -1)
426
    if total_jobs > 0 and passed_jobs >= 0:
427
        return int(float(passed_jobs) / float(total_jobs) * 100.0)
428
429
    return -1
430
431
432
def get_result_color(value=0, ok_thershold=90, warning_thershold=70):
433
    """
434
    Get color code that corresponds to the result.
435
436
    Parameters:
437
    - value : value to check
438
    - ok_thershold : OK threshold value
439
    - warning_thershold : warning thershold value
440
    """
441
    if not(isinstance(value, (int, float)) and
442
           isinstance(ok_thershold, (int, float)) and
443
           isinstance(warning_thershold, (int, float))):
444
        return "lightgrey"
445
446
    # check thresholds
447
    if value >= ok_thershold:
448
        return "green"
449
    elif value >= warning_thershold:
450
        return "yellow"
451
    else:
452
        return "red"
453
454
455
def get_total_builds(repo=None, interval=None):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
456
    """
457
    Query Keen.io database and retrieve total number of builds.
458
459
    Parameters :
460
    - repo : repo name (fe. buildtimetrend/service)
461
    - interval : timeframe, possible values : 'week', 'month', 'year',
462
                 anything else defaults to 'week'
463
    """
464
    if repo is None or not is_readable():
465
        return -1
466
467
    interval_data = check_time_interval(interval)
468
469
    try:
470
        return keen.count_unique(
471
            "build_jobs",
472
            target_property="job.build",
473
            timeframe=interval_data['timeframe'],
474
            max_age=interval_data['max_age'],
475
            filters=[get_repo_filter(repo)]
476
        )
477
    except requests.ConnectionError:
478
        logger.error("Connection to Keen.io API failed")
479
        return -1
480
    except keen.exceptions.KeenApiError as msg:
481
        logger.error("Error in keenio.get_total_builds() : " + str(msg))
482
        return -1
483
484
485
def get_latest_buildtime(repo=None):
486
    """
487
    Query Keen.io database and retrieve buildtime duration of last build.
488
489
    Parameters :
490
    - repo : repo name (fe. buildtimetrend/python-lib)
491
    """
492
    if repo is None or not is_readable():
493
        return -1
494
495
    try:
496
        result = keen.extraction(
497
            "build_jobs",
498
            property_names="job.duration",
499
            latest=1,
500
            filters=[get_repo_filter(repo)]
501
        )
502
    except requests.ConnectionError:
503
        logger.error("Connection to Keen.io API failed")
504
        return -1
505
    except keen.exceptions.KeenApiError as msg:
506
        logger.error("Error in keenio.get_latest_buildtime() : " + str(msg))
507
        return -1
508
509
    if is_list(result) and len(result) > 0 and \
510
            check_dict(result[0], None, ['job']) and \
511
            check_dict(result[0]['job'], None, ['duration']):
512
        return result[0]['job']['duration']
513
514
    return -1
515
516
517
def get_days_since_fail(repo=None):
518
    """
519
    Query Keen.io database and retrieve time since last failed buildjob.
520
521
    Parameters :
522
    - repo : repo name (fe. buildtimetrend/python-lib)
523
    """
524
    if repo is None or not is_readable():
525
        return -1
526
527
    try:
528
        failed_timestamp = keen.maximum(
529
            "build_jobs",
530
            target_property="job.finished_at.timestamp_seconds",
531
            filters=[
532
                get_repo_filter(repo),
533
                {
534
                    "operator": "ne",
535
                    "property_name": "job.result",
536
                    "property_value": "passed"
537
                }
538
            ]
539
        )
540
    except requests.ConnectionError:
541
        logger.error("Connection to Keen.io API failed")
542
        return -1
543
    except keen.exceptions.KeenApiError as msg:
544
        logger.error("Error in keenio.get_days_since_fail() : " + str(msg))
545
        return -1
546
547
    if isinstance(failed_timestamp, (int, float)) and failed_timestamp > 0:
548
        dt_failed = datetime.fromtimestamp(failed_timestamp)
549
        dt_now = datetime.now()
550
        return math.floor((dt_now - dt_failed).total_seconds() / (3600 * 24))
551
552
    return -1
553
554
555
def has_build_id(repo=None, build_id=None):
556
    """
557
    Check if build_id exists in Keen.io database.
558
559
    Parameters :
560
    - repo : repo name (fe. buildtimetrend/python-lib)
561
    - build_id : ID of the build
562
    """
563
    if repo is None or build_id is None:
564
        logger.error("Repo or build_id is not set")
565
        raise ValueError("Repo or build_id is not set")
566
    if not is_readable():
567
        raise SystemError("Keen.io Project ID or API Read Key is not set")
568
569
    try:
570
        count = keen.count(
571
            "build_jobs",
572
            filters=[get_repo_filter(repo), {
573
                "property_name": "job.build",
574
                "operator": "eq",
575
                "property_value": str(build_id)}]
576
        )
577
    except requests.ConnectionError:
578
        logger.error("Connection to Keen.io API failed")
579
        raise SystemError("Connection to Keen.io API failed")
580
    except keen.exceptions.KeenApiError as msg:
581
        logger.error("Error in keenio.has_build_id : " + str(msg))
582
        raise SystemError(msg)
583
584
    return count > 0
585
586
587
def get_all_projects():
588
    """Query Keen.io database and retrieve a list of all projects."""
589
    if not is_readable():
590
        return []
591
592
    try:
593
        result = keen.select_unique(
594
            "build_jobs",
595
            "buildtime_trend.project_name",
596
            max_age=3600 * 24  # cache for 24 hours
597
        )
598
    except requests.ConnectionError:
599
        logger.error("Connection to Keen.io API failed")
600
        return []
601
    except keen.exceptions.KeenApiError as msg:
602
        logger.error("Error in keenio.get_all_projects() : " + str(msg))
603
        return []
604
605
    if is_list(result):
606
        return result
607
608
    return []
609
610
611
def get_repo_filter(repo=None):
612
    """
613
    Return filter for analysis request.
614
615
    Parameters
616
    - repo : repo slug name, fe. buildtimetrend/python-lib
617
    """
618
    if repo is None:
619
        return None
620
621
    return {
622
        "property_name": "{}.project_name".format(KEEN_PROJECT_INFO_NAME),
623
        "operator": "eq",
624
        "property_value": str(repo)
625
    }
626
627
628
def check_time_interval(interval=None):
629
    """
630
    Check time interval and returns corresponding parameters.
631
632
    Parameters :
633
    - interval : timeframe, possible values : 'week', 'month', 'year',
634
                 anything else defaults to 'week'
635
    """
636
    if is_string(interval):
637
        # convert to lowercase
638
        interval = interval.lower()
639
640
        if interval in TIME_INTERVALS:
641
            return TIME_INTERVALS[interval]
642
643
    return TIME_INTERVALS['week']
644