├── .gitignore ├── Makefile ├── README.rst ├── bin ├── autotest └── pip_ ├── inc ├── .gitkeep └── boostrap.mk ├── setup.cfg ├── setup.py └── timecop ├── __init__.py └── tests ├── __init__.py ├── test_freeze.py └── test_travel.py /.gitignore: -------------------------------------------------------------------------------- 1 | # vim swap files 2 | .*.swp 3 | 4 | # python compilation cruft 5 | *.pyc 6 | 7 | # our virtualenv (NB: location can differ it set in Makefile) 8 | .pyenv 9 | 10 | ## CI timestamp files 11 | # time last tests passed 12 | .ci_last_pass 13 | # sentinel to check for new file writes against 14 | .ci_sentinel 15 | 16 | # I don't consider this part of my project 17 | bin/virtualenv.py 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | ########## 3 | # defaults 4 | 5 | SRC=timecop 6 | BIN=bin 7 | 8 | # custom pip script ignores/filters "already installed, use --upgrade" and "cleaning up" msgs 9 | PIP=$(BIN)/pip_ 10 | 11 | PROJECT_NAME=pytimecop 12 | 13 | # project abbreviated name 14 | PROJECT_ABBREV="ptc" 15 | 16 | NOSE_FLAGS="--nocapture" 17 | 18 | 19 | ########## 20 | # - initial "default" rule 21 | # will be used it you just type "make" on the cmdline 22 | .PHONY: all 23 | all: install clean 24 | # I like to cleanup Python projects after every run 25 | 26 | # Includes need to be below the default "all" command or rules defined 27 | # in them will be interpreted as the default/first rule 28 | 29 | ########## 30 | # keep VAR assignments above so can be overridden by users 31 | 32 | -include inc/*.mk 33 | 34 | # I like to cleanup Python projects after every run 35 | .PHONY: install 36 | install: deploy clean 37 | 38 | .PHONY: depends 39 | depends: 40 | @$(PIP) install nose 41 | 42 | .PHONY: compile 43 | compile: depends 44 | 45 | # automatically run tests every time a file changes 46 | .PHONY: autotest 47 | autotest: 48 | @$(BIN)/autotest 49 | 50 | .PHONY: test 51 | test: compile 52 | @nosetests $(SRC)/tests $(NOSE_FLAGS) 53 | 54 | .PHONY: deploy 55 | # TBD put code up somewhere, etc 56 | deploy: test 57 | 58 | .PHONY: clean 59 | clean: 60 | @find $(SRC) -type f -name \*.pyc -print0 | xargs -0 rm -rf 61 | 62 | # purposely don't "rm -rf" the $(PYENV) b/c of risk of bad values wiping a homedir, etc 63 | .PHONY: realclean distclean 64 | realclean: distclean 65 | distclean: clean 66 | @-rm -f $(BIN)/virtualenv.py 67 | @-rm -f .ci_* 68 | 69 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | TimeCop 3 | ======= 4 | 5 | In addition to being one of the best Jean-Claude Van Damme movies EVAR!, TimeCop is also 6 | a killer sweet Ruby gem. This is its Python port. 7 | 8 | Much respect to https://github.com/jtrupiano/ for his TimeCop gem and for 9 | not naming it something like like TimeTester or TimeHelper like I may have. 10 | 11 | Needs more testing, needs docs. 12 | 13 | --------------------- 14 | Current functionality 15 | --------------------- 16 | #. timecop.freeze() supporting floating point/int "seconds since epoch" time specs 17 | #. timecop.freeze() supporting timedelta time specs (use negative numbers to go back) 18 | #. timecop.travel() supports all creation/use methods a freeze() 19 | 20 | ---- 21 | TODO 22 | ---- 23 | #. put updated/latest version on pypi 24 | #. accept date(), and string representations of alternate times 25 | #. test full suite of datetime, time, objects for proper functionality 26 | #. support older (pre 2.7) versions of python - datetime.total_seconds() is new in 2.7 and is currently required for timedelta() time_spec support 27 | 28 | -------------------------------------------------------------------------------- /bin/autotest: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # TODO rewrite this mess in python or something 4 | # bash is difficult (for me) to write largish complex things in 5 | 6 | #series=('\' '|' '/' '-') 7 | #series=('.' 'o' 'O' '*' 'O' 'o') 8 | series=( 9 | '[* ]' 10 | '[ * ]' 11 | '[ * ]' 12 | '[ * ]' 13 | '[ *]' 14 | '[ * ]' 15 | '[ * ]' 16 | '[ * ]' 17 | ) 18 | 19 | # vars for animation 20 | t=0 21 | len=${#series[@]} 22 | 23 | # command to run to do tests 24 | cmd="make test" 25 | 26 | # color codes 27 | color= 28 | color_fail='\e[1;37;41m' 29 | color_errors='\e[0;30;43m' 30 | color_syntax='\e[0;5;30;41m' 31 | #color_pass='\e[0;30;42m\e[K' 32 | color_pass='\e[0;30;42m' 33 | color_reset='\e[0m' 34 | color_testing='\e[0;37;45m' 35 | 36 | # ensure file exists but likely older than a file in cwd 37 | touch --date='1970-01-01' .ci_sentinel 38 | 39 | # intitial time since last pass 40 | date +%s > .ci_last_pass 41 | 42 | # initial messages 43 | msg='<....>' 44 | last_pass='' 45 | 46 | # false, last run did not pass 47 | # used to determine when to update time since last pass 48 | last_run_passed=0 49 | 50 | # columns, gotten from stty 51 | cols_raw="$(stty size | awk '{print $2}')" 52 | 53 | # adjust for animation size, msg size, etc. 54 | cols=$(( $cols_raw-13 )) 55 | 56 | while : 57 | do 58 | # print animation step - NB %b required for color codes in \e form 59 | # the %-((...))s math below is to account for length of $msg so 60 | # background colors like those on syntax errors don't persist 61 | # past time that msg is displayed and a change to "pass" will 62 | # fully reset and wipe old colors from line 63 | # 64 | # otherwise a few red bars/etc might persist even if tests pass 65 | # if they were previously failed and that contition showed a 66 | # background color 67 | printf "\r%b%s [%-6s] %-$(($cols-${#msg}))s%b" "$color" "${series[$t]}" "$msg" "$last_pass" "$color_reset" 68 | 69 | # forward animation to next step 70 | t=$(expr $t + 1) 71 | t=$(expr $t % $len ) 72 | 73 | # pause to keep animation from going too fast 74 | # don't make this too long or latency between 75 | # file write and tests running will increase 76 | sleep 0.5 77 | 78 | # last pass check and compute "time since last passing test" 79 | if [ 1 == $last_run_passed ] 80 | then 81 | last_pass='' 82 | else 83 | # NB there is a "bug" as follows: 84 | # tests pass, .ci_last_pass set 85 | # N minutes go by while you edit 86 | # you write file, test fail 87 | # time reported since last pass = N minutes 88 | # (even though they just failed) 89 | # 90 | # this is desired behavior since tests 91 | # have actually not been proven to pass 92 | # in N minutes -- prompting devs to keep 93 | # cycle short and time between passes 94 | # smaller. 95 | # 96 | # also, we don't know when in those N 97 | # minutes the error was introduced, so 98 | # err on side of "more" 99 | mins="-1" 100 | last="$(cat .ci_last_pass)" 101 | now="$(date +%s)" 102 | secs=$(( $now-$last )) 103 | mins=$(( $secs/60 )) 104 | last_pass="Last pass: ${mins}m ago" 105 | fi 106 | 107 | # look for newly changed files -- trigger tests if output contains >0 lines (files that changed) 108 | if [ "0" -ne "$(find . -not -path '.*.swp' -not -path '*.pyc' -newer .ci_sentinel -type f | wcl)" ] 109 | then 110 | # notify running tests - use current color 111 | color=$color_testing 112 | printf "\r%b%-${cols_raw}s" "$color" "[...running tests...]" 113 | 114 | # run tests 115 | MM="$( $cmd 2>&1)" 116 | 117 | # check return code 118 | if [ $? == 0 ] 119 | then 120 | # passed tests, update vars and continue 121 | color=$color_pass 122 | msg='PASS' 123 | 124 | # update last time tests passed 125 | echo "$(date +%s)" > .ci_last_pass 126 | last_pass='' 127 | last_run_passed=1 128 | else 129 | # tests failed 130 | 131 | last_run_passed=0 132 | 133 | # look for various strings to determine cause of failures 134 | num_failures=$(echo "$MM" | egrep 'failures=[1-9]' | sed -r -e 's/.*failures=([0-9]+).*/\1/') 135 | num_errors=$(echo "$MM" | egrep 'errors=[1-9]' | sed -r -e 's/.*errors=([0-9]+).*/\1/') 136 | 137 | # special check for syntax failures 138 | syntax_errors=$(echo "$MM" | grep ^SyntaxError:) 139 | if [ 0 == $? ] 140 | then 141 | syntax_errors=1 142 | else 143 | syntax_errors=0 144 | fi 145 | 146 | # from "most" to "least" surprising cause 147 | if [ "1" == "$syntax_errors" ] 148 | then 149 | # syntax/compile error 150 | color=$color_syntax 151 | msg='SYNTAX' 152 | elif [ "" != "$num_errors" ] 153 | then 154 | # unexpected exception/error from test 155 | color=$color_errors 156 | msg='ERROR' 157 | else 158 | # test/assertion failure 159 | color=$color_fail 160 | msg='FAIL' 161 | fi 162 | fi 163 | 164 | # update sentinel time so skip test run until next file updated 165 | touch .ci_sentinel 166 | fi 167 | done 168 | 169 | -------------------------------------------------------------------------------- /bin/pip_: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # special version of pip that silences notices that a requirement is already satisfied 4 | # -- so we can run "make depends" often and not get a lot of cruft on stdout 5 | 6 | RVAL=0 7 | { pip $@; RVAL=$?; } | grep -v '^Requirement already satisfied' | grep -v '^Cleaning up...' 8 | exit $RVAL # return same return value that pip did, not the greps in the chain 9 | -------------------------------------------------------------------------------- /inc/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluekelp/pytimecop/95fb315412cf8e9a86fd412ea08c88c8f5637680/inc/.gitkeep -------------------------------------------------------------------------------- /inc/boostrap.mk: -------------------------------------------------------------------------------- 1 | 2 | # rules for bootstrapping a virtualenv to work in 3 | # useful if you get errors about not having pip, etc. :) 4 | 5 | .PHONY: bootstrap 6 | # use system python to setup a local virtualenv 7 | bootstrap: $(PYENV) 8 | 9 | $(PYENV): $(BIN)/virtualenv.py 10 | @python $(BIN)/virtualenv.py --distribute --prompt='($(PROJECT_ABBREV)) ' $(PYENV) 11 | @-rm distribute-*.tar.gz 12 | 13 | # you can always put your own version there and we'll leave it alone 14 | $(BIN)/virtualenv.py: 15 | @echo "Downloading virtualenv from GitHub" 16 | @curl https://raw.github.com/pypa/virtualenv/master/virtualenv.py > $(BIN)/virtualenv.py 17 | 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_build = dev 3 | tag_svn_revision = true 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import sys, os 3 | 4 | version = '0.5.0' 5 | 6 | setup(name='timecop', 7 | version=version, 8 | description="Enables time travel. A port of Ruby's gem of the same name.", 9 | long_description="""\ 10 | Enables time travel. 11 | A port of the Ruby gem of the same name: https://github.com/jtrupiano/timecop. 12 | 13 | Currently supports "freeze" but not "travel" functionality, specified by either\ 14 | a "time since epoch" or datetime.timedelta specification.""", 15 | classifiers=[ 16 | 'Development Status :: 3 - Alpha', 17 | 'Intended Audience :: Developers', 18 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 19 | 'Programming Language :: Python', 20 | 'Topic :: Software Development :: Libraries :: Python Modules', 21 | 'Topic :: Software Development :: Testing', 22 | ], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 23 | keywords='timecop freeze time travel test context', 24 | author='Gabriel Krupa', 25 | author_email='timecop@bluekelp.com', 26 | url='https://github.com/bluekelp/pytimecop', 27 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 28 | include_package_data=True, 29 | zip_safe=True, 30 | install_requires=[ 31 | # -*- Extra requirements: -*- 32 | ], 33 | entry_points=""" 34 | # -*- Entry points: -*- 35 | """, 36 | ) 37 | -------------------------------------------------------------------------------- /timecop/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | 4 | class fake_datetime(datetime.datetime): 5 | """Like datetime.datetime, but always defer to time.time() 6 | 7 | These implementations of now and utcnow are lifted straight from 8 | the standard library documentation, which states that they are 9 | "equivalent" to these expressions, but may provide higher 10 | resolution on systems which support it. 11 | 12 | """ 13 | @classmethod 14 | def now(cls, tz=None): 15 | if tz is None: 16 | return cls.fromtimestamp(time.time()) 17 | else: 18 | return tz.fromutc(cls.utcnow().replace(tzinfo=tz)) 19 | 20 | @classmethod 21 | def utcnow(cls): 22 | return cls.utcfromtimestamp(time.time()) 23 | 24 | class freeze(object): 25 | """ Context to freeze time at a given point in time """ 26 | 27 | def __init__(self, time_spec): 28 | """ Initialize ourselves to be a context for the given alternate time. """ 29 | if isinstance(time_spec, datetime.timedelta): 30 | self.secs_ = time.time() + time_spec.total_seconds() 31 | else: 32 | self.secs_ = float(time_spec) 33 | 34 | def time_(self): 35 | """ A version of time.time() that will return the value we want 36 | the system to think it is. """ 37 | return self.secs_ 38 | 39 | def __enter__(self): 40 | """ Alter time.time() to return the time we want it to be. 41 | Save current time function so can be replaced outside our 42 | context. """ 43 | if hasattr(self, 'old_time_func_'): 44 | # looks like someone is trying to use us again inside ourselves 45 | # for another nested context 46 | raise ValueError('cannot nest time travel with the same instance') 47 | 48 | # replace datetime.datetime 49 | self.old_datetime_ = datetime.datetime 50 | datetime.datetime = fake_datetime 51 | 52 | self.old_time_func_ = time.time # save old 53 | time.time = self.time_ # set new 54 | 55 | def __exit__(self, error_type, value, traceback): 56 | """ Reset time.time() to the value when we found it. """ 57 | time.time = self.old_time_func_ # reset old 58 | del(self.old_time_func_) # forget it 59 | 60 | # restore datetime.datetime 61 | datetime.datetime = self.old_datetime_ 62 | del(self.old_datetime_) 63 | 64 | def actual_time_(self): 65 | return self.old_time_func_() 66 | 67 | class travel(freeze): 68 | """ Context to travel to another time, letting time continue to pass """ 69 | def __init__(self, time_spec): 70 | freeze.__init__(self, time_spec) 71 | # at this point, time.time() *is* actual time 72 | self.time_travel_offset_ = self.secs_ - time.time() # amount of time (in seconds) we're travelling in future (positive values) or past (negative value) 73 | 74 | def time_(self): 75 | return self.actual_time_() + self.time_travel_offset_ 76 | 77 | -------------------------------------------------------------------------------- /timecop/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ This file makes the src/test/ dir a module so python can import things from it """ 2 | -------------------------------------------------------------------------------- /timecop/tests/test_freeze.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import datetime 5 | from datetime import date 6 | from datetime import timedelta 7 | import time 8 | 9 | import timecop 10 | 11 | class TestFreeze(unittest.TestCase): 12 | 13 | SECONDS_IN_A_DAY = 86400 14 | 15 | def test_yesterday(self): 16 | secs = time.time() 17 | secs -= self.SECONDS_IN_A_DAY 18 | 19 | # do it "right" 20 | yesterday = date.today() - timedelta(days=1) 21 | 22 | with timecop.freeze(secs): 23 | self.assertEqual(yesterday, date.today()) 24 | 25 | def test_epoch(self): 26 | with timecop.freeze(0): 27 | # test assumes knowledge of when epoch is but that's ok 28 | self.assertEqual(date(1969, 12, 31), date.today()) 29 | 30 | def test_time_stops_with_freeze(self): 31 | now = time.time() 32 | 33 | with timecop.freeze(now): 34 | # NB: time.sleep() and time.time() resolution should be plenty to detect 35 | # a sleep even of a few milliseconds but we're trading test run time for 36 | # confidence that time is really frozen 37 | time.sleep(0.6) 38 | self.assertEqual(now, time.time()) # time ought not to have changed at all 39 | 40 | def test_can_freeze_to_a_timedelta_object(self): 41 | offset = timedelta(days=-1) 42 | now = time.time() 43 | with timecop.freeze(offset): 44 | self.assertAlmostEqual(now - self.SECONDS_IN_A_DAY, time.time(), delta=0.9) 45 | 46 | def test_can_nest_freezes(self): 47 | secs = time.time() 48 | secs -= self.SECONDS_IN_A_DAY 49 | 50 | yesterday = date.today() - timedelta(days=1) 51 | day_before_yesterday = date.today() - timedelta(days=2) 52 | 53 | with timecop.freeze(secs): 54 | self.assertEqual(yesterday, date.today()) 55 | 56 | secs -= self.SECONDS_IN_A_DAY # take off another day 57 | with timecop.freeze(secs): 58 | self.assertEqual(day_before_yesterday, date.today()) 59 | 60 | # test again after coming out of looped context 61 | self.assertEqual(yesterday, date.today()) 62 | 63 | def test_datetime_today(self): 64 | now = time.time() 65 | 66 | with timecop.freeze(now): 67 | time.sleep(0.6) 68 | self.assertEqual(datetime.datetime.fromtimestamp(now), 69 | datetime.datetime.today()) 70 | 71 | def test_datetime_now(self): 72 | now = time.time() 73 | 74 | with timecop.freeze(now): 75 | time.sleep(0.6) 76 | self.assertEqual(datetime.datetime.fromtimestamp(now), 77 | datetime.datetime.now()) 78 | 79 | def test_datetime_utcnow(self): 80 | now = time.time() 81 | 82 | with timecop.freeze(now): 83 | time.sleep(0.6) 84 | self.assertEqual(datetime.datetime.utcfromtimestamp(now), 85 | datetime.datetime.utcnow()) 86 | -------------------------------------------------------------------------------- /timecop/tests/test_travel.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | from datetime import date 5 | from datetime import timedelta 6 | from datetime import datetime 7 | import time 8 | 9 | import timecop 10 | 11 | class TestTravel(unittest.TestCase): 12 | 13 | def test_time_does_not_stop_with_travel(self): 14 | now = time.time() 15 | 16 | with timecop.travel(now): 17 | # NB: time.sleep() and time.time() resolution should be plenty to detect 18 | # a sleep even of a few milliseconds but we're trading test run time for 19 | # confidence that time is really frozen 20 | sleep_secs = 0.6 21 | time.sleep(sleep_secs) 22 | self.assertNotEqual(now, time.time()) 23 | time_diff = time.time() - now 24 | self.assertTrue( time_diff >= sleep_secs ) 25 | 26 | def test_time_travel_forward(self): 27 | orig_time = time.time() 28 | travel_time = orig_time + 15 29 | 30 | with timecop.travel(travel_time): 31 | self.assertTrue( ( time.time() - travel_time ) <= 0.01 ) # pretty much the same time as we traveled to 32 | self.assertTrue( ( time.time() - orig_time ) >= 10 ) # current time is before original/actual time 33 | 34 | # time is reset to about where we were before (CPU cycles take "real" time to execute, so not exactly the same) 35 | self.assertTrue( (time.time() - orig_time ) <= 0.01, str(locals()) ) 36 | 37 | def test_time_travel_backwards(self): 38 | orig_time = time.time() 39 | travel_time = orig_time - ( 60.0 * 60.0 ) 40 | 41 | with timecop.travel(travel_time): 42 | time.sleep(0.6) 43 | # even after sleeping we're still "behind" actual time 44 | self.assertTrue( time.time() < orig_time ) 45 | 46 | # resets properly 47 | self.assertTrue( time.time() > orig_time, locals() ) 48 | 49 | --------------------------------------------------------------------------------