Initial version of Timex

timex is a datetime processing library allowing you to write
time expressions in a DLS as strings.

Splitting this out from Winchester as a separate library, since it
could be useful elsewhere.
This commit is contained in:
Monsyne Dragon 2014-08-18 17:36:48 +00:00
commit 412eea1dbf
16 changed files with 1748 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
*.py[cod]
# C extensions
*.so
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
var
sdist
develop-eggs
.installed.cfg
lib
lib64
__pycache__
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo

202
LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

2
MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
include README.md
include requirements.txt

159
README.md Normal file
View File

@ -0,0 +1,159 @@
timex
=====
A time expressions library implementing a mini-language for manipulating
datetimes.
Much like regular expressions provide a mini-language for performing certain
operation on strings, Timex's time expressions provide a convenient way of
expressing datetime and date-range operations. These expressions are strings,
and can be safely read from a config file or user input.
A simple example: Say you are writing code that creates a report on phone calls
to a store. You have a call log, perhaps in a database, with a timestamp for
each call, and you need to keep separate counts of calls that happened during
"employee hours", i.e. when employees should be there to answer the phone.
Current policy is that those are 30min before opening til an hour after closing.
That policy may change, however, so you don't want to hardcode it.
Here is code that will get you what you need:
```python
import timex
# store_open and store_close are datetimes for a specific day.
# calls is a list of object with a .timestamp attribute, that is also a
# datetime.
def count_calls(calls, store_open, store_close):
count = 0
# This expression string could come from a config file.
time_expression = timex.parse("$opening - 30m to $closing + 1h")
# Call a compiled expression with keyword args to substitute needed
# variables.
matcher = time_expression(opening=store_open, closing=store_close)
for call in calls:
# Time matchers returned by calling an expression override the
# 'in' operator.
if call.timestamp in matcher:
count += 1
return count
```
Another example: Calculating expiration dates.
Say you need to calculate expiration dates for some items. There are several
types, and each has different rules:
```python
import timex
from datetime import datetime
# These rules could be in a database table or config file.
EXPIRATION_RULES = {
# This one expires at the beginning of the next day.
"thingy_type": "($timestamp + 1d) @ 0h 0m 0s",
# This one is only good for 3 hours
'whatzit_type': "$timestamp + 3h",
# Expires at noon on Dec, 31 of this year.
'foobar_type': "$timestamp @ 12mo 31d 12h 0m 0s"
}
def set_expiration(items):
for item in items:
rule = EXPIRATION_RULES[item.type]
# In real code, you'd probably compile the rules outside the
# loop. It is fairly quick, though.
rule_expr = timex.parse(rule)
exp_timestamp = rule_expr(timestamp=datetime.now())
# You can access the datetime for a Timestamp matcher with
# .timestamp, .begin, or .end, as they will be the same.
# For TimeRanges, .begin is the start of the range, .end is the
# end of the range, and .timestamp is a synonym for .begin
item.expiration = exp_timestamp.timestamp
```
## Using Time Expressions
Time expressions can represent a single timestamp, calculated according to
the expression, or a range of times between a beginning datetime and an end.
In either case the usage is the same:
* Call timex.parse() to get an expression object from your string.
* Call the expression with any values you need as keyword args.
Note that there is a **default** variable, named _$timestamp_ that is
always available to your expressions. If you don't supply a value for it
as a keyword, then the value from _datetime.utcnow()_ is uesd.
* The above call gets you a Timestamp object or a TimeRange object,
depending on whether the expression represents a range, or a single
timestamp. Both of these have the same methods and attributes available.
You can compare a datetime to these objects using the .match() method,
or the _in_ operator (both will produce the same result), or access the
calculated datetimes with the .timestamp, .begin or .end attributes.
## Time Expression Reference and Syntax
### Duration:
A Duration is simply a number with a unit attached, like so:
`6h` or `30m`. Durations can also be ganged together like so `6h 30m 15s`
Valid units are:
| Unit | Description |
|:----:| ------------|
| y | Year |
| mo | Month |
| d | Day |
| h | Hour |
| m | Minute |
| s | Second |
| us | Microsecond |
### Timestamp expression:
Expression that evaluates to a single datetime.
### TimeRange expression:
Expression that evaluates to a range of time, represented by a begin
datetime and an end datetime. Addition, subtraction, or replace operations
on a range will perform the same operation on both the begin and end of the
timerange. Ranges can be created using the `to` operator, or with the special
range functions.
### Special (a.k.a "pinned") Time Ranges:
Special time ranges are generated with the special range functions `hour`, `day`, `month` and `year`.
For example the expression `day($start)` represents "the full day containing time $start". These ranges
are "special" because they remember the timestamp they are created from, and will "wrap around" on addition,
subtraction and replace operations so the timestamp is still within that range.
For example: If `$start` is "2014-08-01 01:00:00", then `day($start)` will be the
range "2014-08-01 00:00:00" to "2014-08-02 00:00:00", but `day($start) + 6h` will be the
range "2014-07-31 00:00:00" to "2014-08-01 06:00:00", so it will still contain $start.
This wrapping of the range will occure as long as the Duration added,
subtracted, or replaced is not larger than the range itself.
Note that the argument to one of these functions can be any timestamp expression, and if no argument is supplied,
it will default to the default variable `$timestamp`. Also, if no argument is supplied, the parens are not required,
like so `day + 6h`.
### Basic Syntax:
| Syntax | Meaning |
| ------------------------- | --------------------------------------------------------------------- |
| $variablename | Access value passed to expression as keyword arg. |
| () | Parentheses can be used to group expressions together for precedence. |
| `expression` + `duration` | Add time to a timestamp or range. |
| `expression` - `duration` | Subtract time from a timestamp or range. |
| `expression` @ `duration` | Replace operator for timestamp or range. Replaces component of datetime(s), similar to datetime's `replace` method |
| `timestamp1` to `timestamp2` | Create a time range beginning at `timestamp1` and ending at `timestamp2` |

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
ply
six>=1.5.2

3
setup.cfg Normal file
View File

@ -0,0 +1,3 @@
[metadata]
description-file = README.md

37
setup.py Normal file
View File

@ -0,0 +1,37 @@
import os
from pip.req import parse_requirements
from setuptools import setup, find_packages
def read(fname):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
req_file = os.path.join(os.path.dirname(__file__), "requirements.txt")
install_reqs = [str(r.req) for r in parse_requirements(req_file)]
setup(
name='timex',
version='0.10.0',
author='Monsyne Dragon',
author_email='mdragon@rackspace.com',
description=("A time expressions library implementing a mini-language "
"for manipulating datetimes"),
license='Apache License (2.0)',
keywords='datetime manipulation transformation DSL',
packages=find_packages(exclude=['tests']),
classifiers=[
'Development Status :: 3 - Alpha',
'License :: OSI Approved :: Apache Software License',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
],
url='https://github.com/StackTach/timex',
scripts=[],
long_description=read('README.md'),
install_requires=install_reqs,
zip_safe=False
)

3
test-requirements.txt Normal file
View File

@ -0,0 +1,3 @@
mock>=1.0
nose
unittest2

0
tests/__init__.py Normal file
View File

380
tests/test_expression.py Normal file
View File

@ -0,0 +1,380 @@
import datetime
#for Python2.6 compatability.
import unittest2 as unittest
import mock
import six
from timex import expression
class TestTimestamp(unittest.TestCase):
def setUp(self):
super(TestTimestamp, self).setUp()
self.dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
self.other_dt = datetime.datetime(2014, 8, 7, 2 ,0, 0, 0)
self.timestamp = expression.Timestamp(self.dt)
def test_timestamp_properties(self):
self.assertEqual(self.dt, self.timestamp.begin)
self.assertEqual(self.dt, self.timestamp.end)
self.assertEqual(self.dt, self.timestamp.timestamp)
def test_match(self):
self.assertTrue(self.timestamp.match(self.dt))
self.assertFalse(self.timestamp.match(self.other_dt))
def test_in(self):
self.assertTrue(self.dt in self.timestamp)
self.assertFalse(self.other_dt in self.timestamp)
def test_add(self):
expected = datetime.datetime(2014, 8, 1, 2, 10, 23, 560)
res = self.timestamp + expression.Duration(microsecond=10)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 2, 10, 33, 550)
res = self.timestamp + expression.Duration(second=10)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 2, 17, 23, 550)
res = self.timestamp + expression.Duration(minute=7)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 3, 10, 23, 550)
res = self.timestamp + expression.Duration(hour=1)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 3, 2, 10, 23, 550)
res = self.timestamp + expression.Duration(day=2)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2015, 2, 1, 2, 10, 23, 550)
res = self.timestamp + expression.Duration(month=6)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2017, 8, 1, 2, 10, 23, 550)
res = self.timestamp + expression.Duration(year=3)
self.assertEqual(res.timestamp, expected)
def test_sub(self):
expected = datetime.datetime(2014, 8, 1, 2, 10, 23, 540)
res = self.timestamp - expression.Duration(microsecond=10)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 2, 10, 13, 550)
res = self.timestamp - expression.Duration(second=10)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 2, 3, 23, 550)
res = self.timestamp - expression.Duration(minute=7)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 1, 10, 23, 550)
res = self.timestamp - expression.Duration(hour=1)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 7, 30, 2, 10, 23, 550)
res = self.timestamp - expression.Duration(day=2)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 2, 1, 2, 10, 23, 550)
res = self.timestamp - expression.Duration(month=6)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2011, 8, 1, 2, 10, 23, 550)
res = self.timestamp - expression.Duration(year=3)
self.assertEqual(res.timestamp, expected)
def test_replace(self):
expected = datetime.datetime(2014, 8, 2, 2, 10, 23, 550)
res = self.timestamp % expression.Duration(day=2)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 6, 1, 2, 10, 23, 550)
res = self.timestamp % expression.Duration(month=6)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2017, 8, 1, 2, 10, 23, 550)
res = self.timestamp % expression.Duration(year=2017)
self.assertEqual(res.timestamp, expected)
expected = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
res = self.timestamp % expression.Duration(hour=0, minute=0, second=0, microsecond=0)
self.assertEqual(res.timestamp, expected)
def test_handle_ambig_duration(self):
d = expression.Duration(hour=10, unknown=2)
self.assertRaises(expression.TimexExpressionError, self.timestamp.__add__, d)
self.assertRaises(expression.TimexExpressionError, self.timestamp.__sub__, d)
self.assertRaises(expression.TimexExpressionError, self.timestamp.__mod__, d)
def test_total_seconds(self):
self.assertFalse(self.timestamp.is_range)
self.assertEqual(self.timestamp.total_seconds(), 0)
class TestTimeRange(unittest.TestCase):
def setUp(self):
super(TestTimeRange, self).setUp()
self.begin_dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
self.end_dt = datetime.datetime(2014, 8, 2, 2 ,10, 23, 550)
self.middle_dt = datetime.datetime(2014, 8, 1, 17 ,30, 10, 25)
self.other_dt = datetime.datetime(2014, 8, 7, 2 ,0, 0, 0)
self.timerange = expression.TimeRange(self.begin_dt, self.end_dt)
def test_timerange_properties(self):
self.assertEqual(self.begin_dt, self.timerange.begin)
self.assertEqual(self.end_dt, self.timerange.end)
self.assertEqual(self.begin_dt, self.timerange.timestamp)
def test_match(self):
#ranges include the beginning.
self.assertTrue(self.timerange.match(self.begin_dt))
self.assertTrue(self.timerange.match(self.middle_dt))
#ranges *don`t* include the end.
self.assertFalse(self.timerange.match(self.end_dt))
self.assertFalse(self.timerange.match(self.other_dt))
def test_in(self):
#ranges include the beginning.
self.assertTrue(self.begin_dt in self.timerange)
self.assertTrue(self.middle_dt in self.timerange)
#ranges *don`t* include the end.
self.assertFalse(self.end_dt in self.timerange)
self.assertFalse(self.other_dt in self.timerange)
def test_add(self):
expected_begin = datetime.datetime(2014, 8, 1, 2, 10, 23, 560)
expected_end = datetime.datetime(2014, 8, 2, 2, 10, 23, 560)
res = self.timerange + expression.Duration(microsecond=10)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 2, 10, 33, 550)
expected_end = datetime.datetime(2014, 8, 2, 2, 10, 33, 550)
res = self.timerange + expression.Duration(second=10)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 2, 17, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 2, 17, 23, 550)
res = self.timerange + expression.Duration(minute=7)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 3, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 3, 10, 23, 550)
res = self.timerange + expression.Duration(hour=1)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 3, 2, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 4, 2, 10, 23, 550)
res = self.timerange + expression.Duration(day=2)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2015, 2, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2015, 2, 2, 2, 10, 23, 550)
res = self.timerange + expression.Duration(month=6)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2017, 8, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2017, 8, 2, 2, 10, 23, 550)
res = self.timerange + expression.Duration(year=3)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_sub(self):
expected_begin = datetime.datetime(2014, 8, 1, 2, 10, 23, 540)
expected_end = datetime.datetime(2014, 8, 2, 2, 10, 23, 540)
res = self.timerange - expression.Duration(microsecond=10)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 2, 10, 13, 550)
expected_end = datetime.datetime(2014, 8, 2, 2, 10, 13, 550)
res = self.timerange - expression.Duration(second=10)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 2, 3, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 2, 3, 23, 550)
res = self.timerange - expression.Duration(minute=7)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 1, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 1, 10, 23, 550)
res = self.timerange - expression.Duration(hour=1)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 7, 30, 2, 10, 23, 550)
expected_end = datetime.datetime(2014, 7, 31, 2, 10, 23, 550)
res = self.timerange - expression.Duration(day=2)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 2, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2014, 2, 2, 2, 10, 23, 550)
res = self.timerange - expression.Duration(month=6)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2011, 8, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2011, 8, 2, 2, 10, 23, 550)
res = self.timerange - expression.Duration(year=3)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_replace(self):
expected_begin = datetime.datetime(2014, 8, 1, 6, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 6, 10, 23, 550)
res = self.timerange % expression.Duration(hour=6)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 6, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2014, 6, 2, 2, 10, 23, 550)
res = self.timerange % expression.Duration(month=6)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2017, 8, 1, 2, 10, 23, 550)
expected_end = datetime.datetime(2017, 8, 2, 2, 10, 23, 550)
res = self.timerange % expression.Duration(year=2017)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 2, 0, 0, 0, 0)
res = self.timerange % expression.Duration(hour=0, minute=0, second=0, microsecond=0)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_handle_ambig_duration(self):
d = expression.Duration(unknown=1)
expected_begin = datetime.datetime(2014, 8, 1, 3, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 3, 10, 23, 550)
res = self.timerange + d
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 1, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 1, 10, 23, 550)
res = self.timerange - d
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 1, 10, 23, 550)
expected_end = datetime.datetime(2014, 8, 2, 1, 10, 23, 550)
res = self.timerange % d
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_total_seconds(self):
self.assertTrue(self.timerange.is_range)
self.assertEqual(self.timerange.total_seconds(), 24*60*60)
class TestPinnedTimeRange(unittest.TestCase):
def setUp(self):
super(TestPinnedTimeRange, self).setUp()
self.begin_dt = datetime.datetime(2014, 8, 1, 1, 0, 0, 0)
self.end_dt = datetime.datetime(2014, 8, 2, 1, 0, 0, 0)
self.middle_dt = datetime.datetime(2014, 8, 1, 17 ,30, 10, 25)
self.other_dt = datetime.datetime(2014, 8, 7, 2 ,0, 0, 0)
self.timerange = expression.PinnedTimeRange(self.begin_dt,
self.end_dt,
self.middle_dt,
'day')
def test_add(self):
expected_begin = datetime.datetime(2014, 8, 1, 2, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 2, 2, 0, 0, 0)
res = self.timerange + expression.Duration(hour=1)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 7, 31, 19, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 1, 19, 0, 0, 0)
res = self.timerange + expression.Duration(hour=18)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_sub(self):
expected_begin = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 2, 0, 0, 0, 0)
res = self.timerange - expression.Duration(hour=1)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 8, 1, 17, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 2, 17, 0, 0, 0)
res = self.timerange - expression.Duration(hour=8)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
def test_replace(self):
expected_begin = datetime.datetime(2014, 8, 1, 2, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 2, 2, 0, 0, 0)
res = self.timerange % expression.Duration(hour=2)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
expected_begin = datetime.datetime(2014, 7, 31, 18, 0, 0, 0)
expected_end = datetime.datetime(2014, 8, 1, 18, 0, 0, 0)
res = self.timerange % expression.Duration(hour=18)
self.assertEqual(res.begin, expected_begin)
self.assertEqual(res.end, expected_end)
class TestDuration(unittest.TestCase):
def setUp(self):
super(TestDuration, self).setUp()
self.second = expression.Duration(second=1)
self.minute = expression.Duration(minute=1)
self.hour = expression.Duration(hour=1)
self.day = expression.Duration(day=1)
self.month = expression.Duration(month=1)
self.year = expression.Duration(year=1)
def test_gt(self):
self.assertTrue(self.hour > self.second)
self.assertFalse(self.second > self.hour)
def test_lt(self):
self.assertTrue(self.second < self.hour)
self.assertFalse(self.hour < self.second)
def test_add(self):
d = self.second + self.hour + self.day
self.assertEqual(d.second, 1)
self.assertEqual(d.hour, 1)
self.assertEqual(d.day, 1)
self.assertIsNone(d.microsecond)
self.assertIsNone(d.minute)
self.assertIsNone(d.month)
self.assertIsNone(d.year)
self.assertIsNone(d.unknown)
def test_as_dict(self):
d = expression.Duration(second=1, hour=1, day=1)
dd = d.as_dict
for unit in ('second', 'hour', 'day'):
self.assertIn(unit, dd)
self.assertEqual(dd[unit], 1)
for unit in ('microsecond', 'minute', 'month', 'year', 'unknown'):
self.assertNotIn(unit, dd)

132
tests/test_parse.py Normal file
View File

@ -0,0 +1,132 @@
import datetime
#for Python2.6 compatability.
import unittest2 as unittest
import mock
import six
import timex
class TestParse(unittest.TestCase):
def setUp(self):
super(TestParse, self).setUp()
self.dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
self.other_dt = datetime.datetime(2014, 8, 7, 3, 20, 0, 0)
def test_var(self):
exp = timex.parse("$test_thingy")
t = exp(test_thingy=self.dt)
self.assertFalse(t.is_range)
self.assertEqual(t.timestamp, self.dt)
def test_timestamp_add(self):
result = datetime.datetime(2014, 8, 2, 4 ,10, 23, 550)
exp = timex.parse("$test_thingy + 1d 2h")
t = exp(test_thingy=self.dt)
self.assertFalse(t.is_range)
self.assertEqual(t.timestamp, result)
def test_timestamp_sub(self):
result = datetime.datetime(2014, 7, 31, 0 ,10, 23, 550)
exp = timex.parse("$test_thingy - 1d 2h")
t = exp(test_thingy=self.dt)
self.assertFalse(t.is_range)
self.assertEqual(t.timestamp, result)
def test_timestamp_replace(self):
result = datetime.datetime(2014, 8, 7, 6 ,10, 23, 550)
exp = timex.parse("$test_thingy @ 7d 6h")
t = exp(test_thingy=self.dt)
self.assertFalse(t.is_range)
self.assertEqual(t.timestamp, result)
def test_timerange(self):
exp = timex.parse("$test_thingy to $other")
t = exp(test_thingy=self.dt, other=self.other_dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, self.dt)
self.assertEqual(t.end, self.other_dt)
def test_timerange_add(self):
result_begin = datetime.datetime(2014, 8, 2, 4 ,10, 23, 550)
result_end = datetime.datetime(2014, 8, 8, 5, 20, 0, 0)
exp = timex.parse("($test_thingy to $other) + 1d 2h")
t = exp(test_thingy=self.dt, other=self.other_dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_timerange_sub(self):
result_begin = datetime.datetime(2014, 7, 31, 0 ,10, 23, 550)
result_end = datetime.datetime(2014, 8, 6, 1, 20, 0, 0)
exp = timex.parse("($test_thingy to $other) - 1d 2h")
t = exp(test_thingy=self.dt, other=self.other_dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_timerange_replace(self):
result_begin = datetime.datetime(2014, 8, 1, 6, 10, 23, 550)
result_end = datetime.datetime(2014, 8, 7, 6, 20, 0, 0)
exp = timex.parse("($test_thingy to $other) @ 6h")
t = exp(test_thingy=self.dt, other=self.other_dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_special_range(self):
result_begin = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
result_end = datetime.datetime(2014, 8, 2, 0, 0, 0, 0)
exp = timex.parse("day($test_thingy)")
t = exp(test_thingy=self.dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
exp = timex.parse("day")
t = exp(timestamp=self.dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_special_range_wrap_replace(self):
result_begin = datetime.datetime(2014, 7, 31, 6, 0, 0, 0)
result_end = datetime.datetime(2014, 8, 1, 6, 0, 0, 0)
exp = timex.parse("day @ 6h")
t = exp(timestamp=self.dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_special_range_wrap_add(self):
result_begin = datetime.datetime(2014, 7, 31, 6, 0, 0, 0)
result_end = datetime.datetime(2014, 8, 1, 6, 0, 0, 0)
exp = timex.parse("day + 6h")
t = exp(timestamp=self.dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_special_range_wrap_sub(self):
result_begin = datetime.datetime(2014, 8, 1, 18, 0, 0, 0)
result_end = datetime.datetime(2014, 8, 2, 18, 0, 0, 0)
exp = timex.parse("day - 6h")
t = exp(timestamp=datetime.datetime(2014, 8, 1, 19, 45, 30, 225))
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)
def test_timerange_ambig_duration(self):
# Ambiguous durations are a bit of a hack to make timex syntax
# compatable with the (much less flexible) syntax for timeranges
# used for some OpenStack projects. (mdragon)
result_begin = datetime.datetime(2014, 8, 1, 2, 0, 0, 0)
result_end = datetime.datetime(2014, 8, 2, 2, 0, 0, 0)
exp = timex.parse("day @ 2")
t = exp(timestamp=self.dt)
self.assertTrue(t.is_range)
self.assertEqual(t.begin, result_begin)
self.assertEqual(t.end, result_end)

7
timex/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from timex.parser import parse
from timex.expression import TimexExpressionError, TimexParserError
from timex.expression import TimexLexerError, TimexError
from timex.expression import Timestamp, TimeRange, PinnedTimeRange, Duration
__version__ = '0.10.0'

479
timex/expression.py Normal file
View File

@ -0,0 +1,479 @@
import logging
import datetime
import abc
import six
logger = logging.getLogger(__name__)
class TimexError(Exception):
pass
class TimexLexerError(TimexError):
pass
class TimexParserError(TimexError):
pass
class TimexExpressionError(TimexError):
pass
@six.add_metaclass(abc.ABCMeta)
class TimeMatcher(object):
_allow_ambig_duration = False
@abc.abstractmethod
def match(self, dt):
"""Does a specific datetime match this?"""
@abc.abstractmethod
def __add__(self, other):
"""Add a duration"""
@abc.abstractmethod
def __sub__(self, other):
"""Subtract a duration"""
@abc.abstractmethod
def __mod__(self, other):
"""Implements the replace operation with a duration"""
def total_seconds(self):
return 0
@property
def is_range(self):
return False
def __nonzero__(self):
return True
def __contains__(self, other):
return self.match(other)
def _check_duration(self, duration):
if isinstance(duration, Duration):
if ((duration.ambiguous and self._allow_ambig_duration)
or not duration.ambiguous):
return True
raise TimexExpressionError("Invalid duration for time operation")
def _dt_replace(self, dt, duration):
return dt.replace(**duration.as_dict)
def _dt_add(self, dt, duration):
d = duration.as_dict
months = d.pop('month', 0)
years = d.pop('year', 0)
if d:
delta = datetime.timedelta(**dict((k+"s",val) for k, val in d.items()))
dt = dt + delta
if months:
newmonth = dt.month + months
years += (newmonth - 1) // 12
newmonth = ((newmonth-1) % 12) + 1
dt = dt.replace(month=newmonth)
if years:
dt = dt.replace(year=(dt.year+years))
return dt
def _dt_sub(self, dt, duration):
d = duration.as_dict
months = d.pop('month', 0)
years = d.pop('year', 0)
if d:
delta = datetime.timedelta(**dict((k+"s",val) for k, val in d.items()))
dt = dt - delta
if months:
newmonth = dt.month - months
years -= (newmonth - 1) // 12
newmonth = ((newmonth-1) % 12) + 1
dt = dt.replace(month=newmonth)
if years:
dt = dt.replace(year=(dt.year-years))
return dt
class Timestamp(TimeMatcher):
"""This is a wrapper on a datetime that has the same
interface as TimeRange"""
def __init__(self, dt):
self.timestamp = dt
@property
def begin(self):
return self.timestamp
@property
def end(self):
return self.timestamp
def match(self, dt):
return self.timestamp == dt
def __add__(self, other):
self._check_duration(other)
return Timestamp(self._dt_add(self.timestamp, other))
def __sub__(self, other):
self._check_duration(other)
return Timestamp(self._dt_sub(self.timestamp, other))
def __mod__(self, other):
self._check_duration(other)
return Timestamp(self._dt_replace(self.timestamp, other))
def __repr__(self):
return "Timestamp for %r" % self.timestamp
class TimeRange(TimeMatcher):
_allow_ambig_duration = True
def __init__(self, begin, end):
self.begin = begin
self.end = end
@property
def timestamp(self):
return self.begin
def total_seconds(self):
delta = self.end - self.begin
return delta.seconds + (delta.days * 24 * 3600) + (delta.microseconds * 1e-6)
def __nonzero__(self):
return self.total_seconds() > 0
@property
def is_range(self):
return True
def match(self, dt):
"""TimeRanges match datetimes from begin (inclusive) to end (exclusive)"""
return dt >= self.begin and dt < self.end
def __add__(self, other):
self._check_duration(other)
duration = other.in_context(self)
begin = self._dt_add(self.begin, duration)
end = self._dt_add(self.end, duration)
return TimeRange(begin, end)
def __sub__(self, other):
self._check_duration(other)
duration = other.in_context(self)
begin = self._dt_sub(self.begin, duration)
end = self._dt_sub(self.end, duration)
return TimeRange(begin, end)
def __mod__(self, other):
self._check_duration(other)
duration = other.in_context(self)
begin = self._dt_replace(self.begin, duration)
end = self._dt_replace(self.end, duration)
return TimeRange(begin, end)
def next(self):
begin = self.end
end = self._dt_add(begin, Duration(second=self.total_seconds()))
return TimeRange(begin, end)
def prev(self):
end = self.begin
begin = self._dt_sub(end, Duration(second=self.total_seconds()))
return TimeRange(begin, end)
def __repr__(self):
return "TimeRange from %r to %r" % (self.begin, self.end)
def pin(self, dt, unit):
return PinnedTimeRange(self.begin, self.end, dt, unit)
class PinnedTimeRange(TimeRange):
def __init__(self, begin, end, pinned_to, unit):
super(PinnedTimeRange, self).__init__(begin, end)
self.pinned_to = pinned_to
self.unit = unit
self.duration = Duration(**{unit: 1})
def _pin_adjust(self, time_range):
if self.pinned_to in time_range:
return time_range.pin(self.pinned_to, self.unit)
while time_range.begin > self.pinned_to:
time_range = time_range.prev()
while time_range.end <= self.pinned_to:
time_range = time_range.next()
return time_range.pin(self.pinned_to, self.unit)
def __add__(self, other):
time_range = super(PinnedTimeRange, self).__add__(other)
if other < self.duration:
return self._pin_adjust(time_range)
return self.time_range
def __sub__(self, other):
time_range = super(PinnedTimeRange, self).__sub__(other)
if other < self.duration:
return self._pin_adjust(time_range)
return self.time_range
def __mod__(self, other):
time_range = super(PinnedTimeRange, self).__mod__(other)
if other < self.duration:
return self._pin_adjust(time_range)
return self.time_range
def __repr__(self):
return "PinnedTimeRange from %r to %r. Pinned to %s(%r)" % (self.begin, self.end, self.unit, self.pinned_to)
class Environment(dict):
def time_func_hour(self, timestamp):
dt = timestamp.timestamp
begin = dt.replace(minute=0, second=0, microsecond=0)
end = begin + datetime.timedelta(hours=1)
return PinnedTimeRange(begin, end, dt, 'hour')
def time_func_day(self, timestamp):
dt = timestamp.timestamp
begin = dt.replace(hour=0, minute=0, second=0, microsecond=0)
end = begin + datetime.timedelta(days=1)
return PinnedTimeRange(begin, end, dt, 'day')
def time_func_month(self, timestamp):
dt = timestamp.timestamp
begin = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = Timestamp(begin) + Duration(month=1)
return PinnedTimeRange(begin, end.timestamp, dt, 'month')
def time_func_year(self, timestamp):
dt = timestamp.timestamp
begin = dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = Timestamp(begin) + Duration(year=1)
return PinnedTimeRange(begin, end.timestamp, dt, 'year')
@six.add_metaclass(abc.ABCMeta)
class TimeExpression(object):
@abc.abstractmethod
def apply(self, env):
"""Apply the expression to a given set of arguments.
:param env: a dictionary-like object. expression functions should be methods
on this object with names beginning with 'time_func_'
returns: TimeMatcher instance
"""
def __call__(self, **kw):
env = Environment()
env.update(kw)
if 'timestamp' not in env:
env['timestamp'] = datetime.datetime.utcnow()
return self.apply(env)
class TimeRangeExpression(TimeExpression):
def __init__(self, begin, end):
self.begin = begin
self.end = end
def __repr__(self):
return '%s(%r, %r)' % (self.__class__.__name__, self.begin, self.end)
def apply(self, env):
begin = self.begin.apply(env)
end = self.end.apply(env)
return TimeRange(begin.timestamp, end.timestamp)
class TimeRangeFunction(TimeRangeExpression):
def __init__(self, func_name, expr=None):
self.func_name = func_name
if expr is None:
expr = Variable('timestamp')
self.expr = expr
def __repr__(self):
return '%s %s(%r)' % (self.__class__.__name__, self.func_name, self.expr)
def apply(self, env):
arg = self.expr.apply(env)
try:
func = getattr(env, "time_func_%s" % self.func_name)
except AttributeError:
raise TimexExpressionError("Unknown Function %s" % self.func_name)
return func(arg)
class Variable(TimeExpression):
def __init__(self, name):
self.name = name
def __repr__(self):
return "%s (%s)" % (self.__class__.__name__, self.name)
def apply(self, env):
if self.name not in env:
raise TimexExpressionError("Variable %s not defined" % self.name)
return Timestamp(env[self.name])
class Operation(TimeExpression):
def __init__(self, expr, duration):
if duration.ambiguous and not isinstance(expr, TimeRangeExpression):
raise TimexParserError("Durations must have unit for "
"TimestampExpressions")
self.expr = expr
self.duration = duration
def __repr__(self):
return '%s(%r, %r)' % (self.__class__.__name__, self.expr, self.duration)
class Replace(Operation):
def apply(self, env):
val = self.expr.apply(env)
return val % self.duration
class Plus(Operation):
def apply(self, env):
val = self.expr.apply(env)
return val + self.duration
class Minus(Operation):
def apply(self, env):
val = self.expr.apply(env)
return val - self.duration
class Duration(object):
UNIT_SIZES = {'year': 365*24*60*60,
'month': 28*24*60*60,
'day': 24*60*60,
'hour': 60*60,
'minute': 60,
'second': 1,
'microsecond': 1e-6}
UNITS = ('year',
'month',
'day',
'hour',
'minute',
'second',
'microsecond',)
def __init__(self, year=None, month=None, day=None, hour=None,
minute=None, second=None, microsecond=None, unknown=None):
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.microsecond = microsecond
self.unknown = unknown
@property
def ambiguous(self):
return self.unknown is not None
@property
def as_dict(self):
d = dict()
for unit in self.UNITS:
val = getattr(self, unit)
if val is not None:
d[unit] = val
if self.ambiguous:
d['unknown'] = self.unknown
return d
def in_context(self, timerange):
if not self.ambiguous:
return self
d = abs(timerange.total_seconds())
if d >= self.UNIT_SIZES['year']:
unit = 'month'
elif d >= self.UNIT_SIZES['month']:
unit = 'day'
elif d >= self.UNIT_SIZES['day']:
unit = 'hour'
elif d >= self.UNIT_SIZES['hour']:
unit = 'minute'
elif d >= self.UNIT_SIZES['minute']:
unit = 'second'
else:
unit = microsecond
vals = self.as_dict
del vals['unknown']
if unit in vals:
vals[unit] += self.unknown
else:
vals[unit] = self.unknown
return Duration(**vals)
def __add__(self, other):
result = self.as_dict
o = other.as_dict
for unit in o:
if unit in result:
result[unit] += o[unit]
else:
result[unit] = o[unit]
return Duration(**result)
def __repr__(self):
return '%s %s' % (self.__class__.__name__, str(self.as_dict))
def __gt__(self, other):
for unit in self.UNITS:
our_val = getattr(self, unit)
other_val = getattr(other, unit)
if our_val is not None and other_val is not None:
return our_val > other_val
elif our_val is not None and other_val is None:
return True
elif our_val is None and other_val is not None:
return False
return False
def __lt__(self, other):
for unit in self.UNITS:
our_val = getattr(self, unit)
other_val = getattr(other, unit)
if our_val is not None and other_val is not None:
return our_val < other_val
elif our_val is not None and other_val is None:
return False
elif our_val is None and other_val is not None:
return True
return False
def __eq__(self, other):
for unit in self.UNITS:
our_val = getattr(self, unit)
other_val = getattr(other, unit)
if our_val != other_val:
return False
return True

102
timex/lexer.py Normal file
View File

@ -0,0 +1,102 @@
import sys
import logging
import ply.lex
from timex.expression import TimexLexerError
logger = logging.getLogger(__name__)
class TimexLexer(object):
"""Lexing/tokenising for time expressions"""
def __init__(self, debug=False):
self.debug = debug
if not self.__doc__:
raise TimexLexerError("Docstring information is missing. "
"Timex uses PLY which requires docstrings for "
"configuration.")
self.lexer = ply.lex.lex(module=self,
debug=self.debug,
errorlog=logger)
self.lexer.string_value = None
self.latest_newline = 0
def input(self, string):
self.lexer.input(string)
def token(self):
token = self.lexer.token()
if token is None:
if self.lexer.string_value is not None:
raise TimexLexerError("Unexpected EOF in expression")
else:
token.col = token.lexpos - self.latest_newline
return token
reserved_words = { 'to' : 'TO',
'us' : 'MICROSECOND',
's' : 'SECOND',
'sec' : 'SECOND',
'm' : 'MINUTE',
'min' : 'MINUTE',
'h' : 'HOUR',
'hr' : 'HOUR',
'd' : 'DAY',
'mo' : 'MONTH',
'y' : 'YEAR',
'yr' : 'YEAR',
}
tokens = ('NUMBER',
'PLUS',
'MINUS',
'REPLACE',
'RPAREN',
'LPAREN',
'VAR',
'IDENTIFIER') + tuple(set(reserved_words.values()))
t_PLUS = r'\+'
t_MINUS = r'-'
t_REPLACE = r'@'
t_VAR = r'\$'
t_LPAREN = r'\('
t_RPAREN = r'\)'
def t_IDENTIFIER(self, t):
r'[a-zA-Z_][a-zA-Z0-9_]*'
t.type = self.reserved_words.get(t.value, 'IDENTIFIER')
return t
def t_NUMBER(self, t):
r'\d+'
t.value = int(t.value)
return t
def t_newline(self, t):
r'\n+'
t.lexer.lineno += len(t.value)
self.latest_newline = t.lexpos
t_ignore = ' \t'
def t_error(self, t):
raise TimexLexerError('Error on line %s, col %s: Unexpected character:'
' %s ' % (t.lexer.lineno,
t.lexpos - self.latest_newline,
t.value[0]))
if __name__ == '__main__':
logging.basicConfig()
lexer = TimexLexer(debug=True)
lexer.input(sys.stdin.read())
token = lexer.token()
while token:
print('%-20s%s' % (token.value, token.type))
token = lexer.token()

195
timex/parser.py Normal file
View File

@ -0,0 +1,195 @@
import sys, os
import logging
import ply.yacc
from timex.lexer import TimexLexer
from timex.expression import TimexParserError
from timex.expression import Replace, Plus, Minus
from timex.expression import Duration, Variable
from timex.expression import TimeRangeFunction, TimeRangeExpression
"""
Parse Time Expression:
BNF:
--------------------------
time_expression : timerange_expression
| timestamp_expression
timerange_expression : timestamp_expression TO timestamp_expression
| timestamp_expression PLUS duration
| timestamp_expression MINUS duration
| timerange_expression REPLACE duration
| LPAREN timerange_expression RPAREN
| range_function
timestamp_expression : timestamp_expression PLUS duration
| timestamp_expression MINUS duration
| timestamp_expression REPLACE duration
| LPAREN timestamp_expression RPAREN
| variable
range_function : IDENTIFIER LPAREN timestamp_expression RPAREN
| IDENTIFIER
variable : VAR IDENTIFIER
duration : duration duration
| NUMBER unit
| NUMBER
unit : SECOND
| MICROSECOND
| MINUTE
| HOUR
| DAY
| MONTH
| YEAR
"""
logger = logging.getLogger(__name__)
def parse(string):
return TimexParser().parse(string)
class TimexParser(object):
""" LALR parser for time expression mini-language."""
tokens = TimexLexer.tokens
def __init__(self, debug=False, lexer_class=None, start='time_expression'):
self.debug = debug
self.start = start
if not self.__doc__:
raise TimexParserError("Docstring information is missing. "
"Timex uses PLY which requires docstrings for "
"configuration.")
self.lexer_class = lexer_class or TimexLexer
def _parse_table(self):
tabdir = os.path.dirname(__file__)
try:
module_name = os.path.splitext(os.path.split(__file__)[1])[0]
except:
module_name = __name__
table_module = '_'.join([module_name, self.start, 'parsetab'])
return (tabdir, table_module)
def parse(self, string):
lexer = self.lexer_class(debug=self.debug)
tabdir, table_module = self._parse_table()
parser = ply.yacc.yacc(module=self,
debug=self.debug,
tabmodule = table_module,
outputdir = tabdir,
write_tables=0,
start = self.start,
errorlog = logger)
return parser.parse(string, lexer=lexer)
precedence = [
('left', 'TO'),
('left', 'PLUS', 'MINUS'),
('left', 'REPLACE'),
('right', 'VAR'),
]
def p_error(self, t):
raise TimexParserError('Parse error at %s:%s near token %s (%s)' %
(t.lineno, t.col, t.value, t.type))
def p_time_expression(self, p):
"""time_expression : timerange_expression
| timestamp_expression
"""
p[0] = p[1]
def p_timerange_to(self, p):
'timerange_expression : timestamp_expression TO timestamp_expression'
p[0] = TimeRangeExpression(p[1], p[3])
def p_timerange_replace(self, p):
"""timerange_expression : timerange_expression REPLACE duration"""
p[0] = Replace(p[1], p[3])
def p_timerange_plus(self, p):
"""timerange_expression : timerange_expression PLUS duration"""
p[0] = Plus(p[1], p[3])
def p_timerange_minus(self, p):
"""timerange_expression : timerange_expression MINUS duration"""
p[0] = Minus(p[1], p[3])
def p_timerange_parens(self, p):
"""timerange_expression : LPAREN timerange_expression RPAREN"""
p[0] = p[2]
def p_timerange_function(self, p):
"""timerange_expression : range_function"""
p[0] = p[1]
def p_timestamp_replace(self, p):
"""timestamp_expression : timestamp_expression REPLACE duration"""
p[0] = Replace(p[1], p[3])
def p_timestamp_plus(self, p):
"""timestamp_expression : timestamp_expression PLUS duration"""
p[0] = Plus(p[1], p[3])
def p_timestamp_minus(self, p):
"""timestamp_expression : timestamp_expression MINUS duration"""
p[0] = Minus(p[1], p[3])
def p_timestamp_parens(self, p):
"""timestamp_expression : LPAREN timestamp_expression RPAREN"""
p[0] = p[2]
def p_timestamp_variable(self, p):
"""timestamp_expression : variable"""
p[0] = p[1]
def p_range_function_expr(self, p):
"""range_function : IDENTIFIER LPAREN timestamp_expression RPAREN"""
p[0] = TimeRangeFunction(p[1], p[3])
def p_range_function(self, p):
"""range_function : IDENTIFIER"""
p[0] = TimeRangeFunction(p[1])
def p_varible(self, p):
'variable : VAR IDENTIFIER'
p[0] = Variable(p[2])
def p_duration_unit(self, p):
"""duration : NUMBER unit"""
p[0] = Duration(**{p[2]: p[1]})
def p_duration_number(self, p):
"""duration : NUMBER"""
p[0] = Duration(unknown=p[1])
def p_duration_duration(self, p):
"""duration : duration duration"""
p[0] = p[1] + p[2]
def p_unit(self, p):
"""unit : SECOND
| MICROSECOND
| MINUTE
| HOUR
| DAY
| MONTH
| YEAR"""
unit = TimexLexer.reserved_words[p[1]]
unit = unit.lower()
p[0] = unit

15
tox.ini Normal file
View File

@ -0,0 +1,15 @@
[tox]
envlist = py26,py27
[testenv]
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
setenv = VIRTUAL_ENV={envdir}
commands =
nosetests tests
sitepackages = False