From 412eea1dbf8343998ebfc69bb2e178a9a36e5dfd Mon Sep 17 00:00:00 2001 From: Monsyne Dragon Date: Mon, 18 Aug 2014 17:36:48 +0000 Subject: [PATCH] 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. --- .gitignore | 30 +++ LICENSE | 202 +++++++++++++++++ MANIFEST.in | 2 + README.md | 159 +++++++++++++ requirements.txt | 2 + setup.cfg | 3 + setup.py | 37 +++ test-requirements.txt | 3 + tests/__init__.py | 0 tests/test_expression.py | 380 +++++++++++++++++++++++++++++++ tests/test_parse.py | 132 +++++++++++ timex/__init__.py | 7 + timex/expression.py | 479 +++++++++++++++++++++++++++++++++++++++ timex/lexer.py | 102 +++++++++ timex/parser.py | 195 ++++++++++++++++ tox.ini | 15 ++ 16 files changed, 1748 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_expression.py create mode 100644 tests/test_parse.py create mode 100644 timex/__init__.py create mode 100644 timex/expression.py create mode 100644 timex/lexer.py create mode 100644 timex/parser.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a735f8b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e06d208 --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..8afbefe --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..396c4d9 --- /dev/null +++ b/README.md @@ -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` | + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4781cd4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +ply +six>=1.5.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8c28267 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[metadata] +description-file = README.md + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1af41d1 --- /dev/null +++ b/setup.py @@ -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 +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..02e37d7 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +mock>=1.0 +nose +unittest2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_expression.py b/tests/test_expression.py new file mode 100644 index 0000000..5728fcc --- /dev/null +++ b/tests/test_expression.py @@ -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) + diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..eb62be8 --- /dev/null +++ b/tests/test_parse.py @@ -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) + diff --git a/timex/__init__.py b/timex/__init__.py new file mode 100644 index 0000000..9d4d2fc --- /dev/null +++ b/timex/__init__.py @@ -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' + diff --git a/timex/expression.py b/timex/expression.py new file mode 100644 index 0000000..a468ba2 --- /dev/null +++ b/timex/expression.py @@ -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 + diff --git a/timex/lexer.py b/timex/lexer.py new file mode 100644 index 0000000..7cd0cde --- /dev/null +++ b/timex/lexer.py @@ -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() + diff --git a/timex/parser.py b/timex/parser.py new file mode 100644 index 0000000..6cce215 --- /dev/null +++ b/timex/parser.py @@ -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 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9afa955 --- /dev/null +++ b/tox.ini @@ -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 +