commit 412eea1dbf8343998ebfc69bb2e178a9a36e5dfd Author: Monsyne Dragon Date: Mon Aug 18 17:36:48 2014 +0000 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. 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 +