Skip to content

Commit

Permalink
Implement build time estimates (closes #15)
Browse files Browse the repository at this point in the history
  • Loading branch information
textbook committed Apr 14, 2016
1 parent e4cb66d commit 0621421
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 26 deletions.
19 changes: 12 additions & 7 deletions flash/services/codeship.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ def format_data(cls, data):
:py:class:`dict`: The re-formatted data.
"""
builds = [
cls.format_build(build) for build in data.get('builds', [])[:5]
]
builds = [cls.format_build(build) for build in data.get('builds', [])]
if builds and builds[0]['outcome'] == 'working':
cls.estimate_time(builds[0], builds[1:])
return dict(
builds=builds,
builds=builds[:4],
health=health_summary(builds),
name=data.get('repository_name'),
)
Expand All @@ -80,12 +80,17 @@ def format_build(cls, build):
status = build.get('status')
if status not in cls.OUTCOMES:
logger.warning('unknown status: %s', status)
start, finish, elapsed = elapsed_time(
build.get('started_at'),
build.get('finished_at'),
)
return dict(
author=build.get('github_username'),
elapsed=elapsed_time(
build.get('started_at'),
build.get('finished_at'),
duration=(
None if start is None or finish is None else finish - start
),
elapsed=elapsed,
message=truncate(build.get('message')),
outcome=cls.OUTCOMES.get(status),
started_at=start,
)
22 changes: 22 additions & 0 deletions flash/services/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Core service description."""

from abc import ABCMeta, abstractmethod
from datetime import datetime

from .utils import naturaldelta


class Service(metaclass=ABCMeta):
Expand Down Expand Up @@ -64,3 +67,22 @@ def from_config(cls, **config):
))
instance = cls(**config)
return instance

@staticmethod
def estimate_time(current, previous):
if current.get('started_at') is None:
current['elapsed'] = 'estimate not available'
return
usable = [
build for build in previous if build['outcome'] == 'passed' and
build['duration'] is not None
]
if not usable:
current['elapsed'] = 'estimate not available'
return
average_duration = int(sum(build['duration'] for build in usable) /
float(len(usable)))
finish = current['started_at'] + average_duration
remaining = (datetime.fromtimestamp(finish) -
datetime.now()).total_seconds()
current['elapsed'] = '{} left'.format(naturaldelta(remaining))
21 changes: 13 additions & 8 deletions flash/services/travis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import requests

from .core import Service
from .utils import health_summary, naturaldelta, truncate
from .utils import elapsed_time, health_summary, truncate

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -74,10 +74,12 @@ def format_data(self, data):
commits = {commit['id']: commit for commit in data.get('commits', [])}
builds = [
self.format_build(build, commits.get(build.get('commit_id'), {}))
for build in data.get('builds', [])[:5]
for build in data.get('builds', [])
]
if builds and builds[0]['outcome'] == 'working':
self.estimate_time(builds[0], builds[1:])
return dict(
builds=builds,
builds=builds[:4],
health=health_summary(builds),
name=self.repo,
)
Expand All @@ -97,14 +99,17 @@ def format_build(cls, build, commit):
status = build.get('state')
if status not in cls.OUTCOMES:
logger.warning('unknown status: %s', status)
try:
elapsed = 'took {}'.format(naturaldelta(int(build.get('duration'))))
except (TypeError, ValueError):
logger.exception('failed to generate elapsed time')
elapsed = 'elapsed time not available'
start, finish, elapsed = elapsed_time(
build.get('started_at'),
build.get('finished_at'),
)
return dict(
author=commit.get('author_name'),
duration=(
None if start is None or finish is None else finish - start
),
elapsed=elapsed,
message=truncate(commit.get('message', '')),
outcome=cls.OUTCOMES.get(status),
started_at=start,
)
52 changes: 47 additions & 5 deletions flash/services/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,56 @@ def elapsed_time(start, end):
end (:py:class:`str`): The activity end time.
Returns:
:py:class:`str`: The humanized elapsed time.
:py:class:`tuple`: The start and end times and humanized elapsed
time.
"""
try:
return 'took {}'.format(naturaldelta(parse(end) - parse(start)))
except (AttributeError, ValueError):
start_time = safe_parse(start)
end_time = safe_parse(end)
if start_time is None or end_time is None:
logger.exception('failed to generate elapsed time')
return 'elapsed time not available'
text = 'elapsed time not available'
else:
text = 'took {}'.format(naturaldelta(parse(end) - parse(start)))
return to_utc_timestamp(start_time), to_utc_timestamp(end_time), text


def to_utc_timestamp(date_time):
"""Convert a naive or timezone-aware datetime to UTC timestamp.
Arguments:
date_time (:py:class:`datetime.datetime`): The datetime to
convert.
Returns:
:py:class:`int`: The timestamp (in seconds).
"""
if date_time is None:
return
if date_time.tzname is None:
timestamp = date_time.replace(tzinfo=timezone.utc).timestamp()
else:
timestamp = date_time.timestamp()
return int(round(timestamp, 0))


def safe_parse(time):
"""Parse a string without throwing an error.
Arguments:
time (:py:class:`str`): The string to parse.
Returns:
:py:class:`datetime.datetime`: The parsed datetime.
"""
if time is None:
return
try:
return parse(time)
except (OverflowError, ValueError):
pass


def occurred(at_):
Expand Down
4 changes: 4 additions & 0 deletions tests/services/test_codeship_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ def test_formatting():
name='foo',
builds=[dict(
author='textbook',
duration=363,
elapsed='took six minutes',
message='hello world',
outcome='passed',
started_at=1459551843,
)],
health='ok',
)
Expand All @@ -96,9 +98,11 @@ def test_unfinished_formatting(warning):
name='foo',
builds=[dict(
author='textbook',
duration=None,
elapsed='elapsed time not available',
message='some much longer...',
outcome=None,
started_at=1459551843,
)],
health='neutral',
)
Expand Down
32 changes: 32 additions & 0 deletions tests/services/test_core_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import OrderedDict
from datetime import datetime

import pytest

Expand Down Expand Up @@ -51,3 +52,34 @@ def test_required_config(config):
])
def test_url_builder(input_,expected):
assert Test().url_builder(*input_) == expected


def test_build_estimate_unstarted():
current = {'started_at': None}

Service.estimate_time(current, [])

assert current['elapsed'] == 'estimate not available'


def test_build_estimate_no_history():
current = {'started_at': 123456789}

Service.estimate_time(current, [])

assert current['elapsed'] == 'estimate not available'


def test_build_estimate_usable():
current = {'started_at': int(datetime.now().timestamp())}
previous = [
{'outcome': 'passed', 'duration': 610},
{'outcome': 'passed', 'duration': 600},
{'outcome': 'passed', 'duration': 605},
]

Service.estimate_time(current, previous)

assert current['elapsed'] == 'ten minutes left'


1 change: 0 additions & 1 deletion tests/services/test_github_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from collections import OrderedDict
from datetime import datetime, timedelta
from unittest import mock

Expand Down
20 changes: 16 additions & 4 deletions tests/services/test_service_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,22 @@ def test_occurred(exception, input_, expected, logged):


@pytest.mark.parametrize('input_, expected, logged', [
((None, None), 'elapsed time not available', True),
(('2011-12-13T14:15:16', None), 'elapsed time not available', True),
((None, '2011-12-13T14:15:16'), 'elapsed time not available', True),
(('2011-12-11T02:15:16', '2011-12-13T14:15:16'), 'took two days', False),
((None, None), (None, None, 'elapsed time not available'), True),
(
('2011-12-13T14:15:16', None),
(1323785716, None, 'elapsed time not available'),
True,
),
(
(None, '2011-12-13T14:15:16'),
(None, 1323785716, 'elapsed time not available'),
True,
),
(
('2011-12-11T02:15:16', '2011-12-13T14:15:16'),
(1323569716, 1323785716, 'took two days'),
False,
),
])
@mock.patch('flash.services.utils.logger.exception')
def test_elapsed_time(exception, input_, expected, logged):
Expand Down
7 changes: 6 additions & 1 deletion tests/services/test_travis_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ def test_formatting(service):
response = dict(
builds=[dict(
commit_id=123456,
duration=567,
finished_at='2016-04-14T20:57:07Z',
started_at='2016-04-14T20:47:40Z',
state='passed',
)],
commits=[dict(
Expand All @@ -81,9 +82,11 @@ def test_formatting(service):
name='foo/bar',
builds=[dict(
author='alice',
duration=567,
elapsed='took nine minutes',
message='hello world',
outcome='passed',
started_at=1460666860,
)],
health='ok',
)
Expand All @@ -109,9 +112,11 @@ def test_unfinished_formatting(warning, service):
name='foo/bar',
builds=[dict(
author='alice',
duration=None,
elapsed='elapsed time not available',
message='some much longer...',
outcome=None,
started_at=None,
)],
health='neutral',
)
Expand Down

0 comments on commit 0621421

Please sign in to comment.