Add PEP8 check and fix related issues
- Add PEP8 section to tox.ini - Add hacking to requirements to enforce OpenStack style requirements - Change setup.py to use PBR - Add setup.cfg - Fix formatting issues flagged by flake8 check - Add copyright notices to all remaining files - Update .gitignore file - Fix bug in expression.py where a variable was set incorrectly Change-Id: I634adba3a44b2bcebb4d8c5620cbade28c6c489a
This commit is contained in:
parent
686f7063e4
commit
fb16776f9d
5
.gitignore
vendored
5
.gitignore
vendored
@ -28,3 +28,8 @@ nosetests.xml
|
|||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|
||||||
|
# IDE Project Files
|
||||||
|
*.project
|
||||||
|
*.pydev*
|
||||||
|
*.idea
|
||||||
|
@ -1 +1,3 @@
|
|||||||
-e .
|
hacking>=0.10.0,<0.11
|
||||||
|
ply
|
||||||
|
six>=1.5.2
|
||||||
|
22
setup.cfg
22
setup.cfg
@ -1,3 +1,25 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
description-file = README.md
|
description-file = README.md
|
||||||
|
name = timex
|
||||||
|
version = 0.21
|
||||||
|
author = Monsyne Dragon
|
||||||
|
author_email = mdragon@rackspace.com
|
||||||
|
summary = A time expressions library implementing a mini-language for manipulating datetimes
|
||||||
|
license = Apache-2
|
||||||
|
keywords =
|
||||||
|
datetime
|
||||||
|
manipulation
|
||||||
|
transformation
|
||||||
|
DSL
|
||||||
|
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
|
||||||
|
|
||||||
|
home-page = https://github.com/stackforge/stacktach-timex
|
||||||
|
|
||||||
|
[files]
|
||||||
|
packages =
|
||||||
|
timex
|
||||||
|
49
setup.py
49
setup.py
@ -1,49 +1,8 @@
|
|||||||
import os
|
#!/usr/bin/env python
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
def read(fname):
|
|
||||||
return open(os.path.join(os.path.dirname(__file__), fname)).read()
|
|
||||||
|
|
||||||
desc = """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.
|
|
||||||
|
|
||||||
Read README.md for syntax and examples.
|
|
||||||
"""
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='timex',
|
setup_requires=['pbr'],
|
||||||
version='0.20.0',
|
pbr=True,
|
||||||
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/stackforge/stacktach-timex',
|
|
||||||
scripts=[],
|
|
||||||
long_description=desc,
|
|
||||||
install_requires=[
|
|
||||||
"ply",
|
|
||||||
"six >= 1.5.2",
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
zip_safe=False
|
|
||||||
)
|
)
|
||||||
|
@ -1,19 +1,33 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2014 Rackspace Hosting.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
#for Python2.6 compatability.
|
# for Python2.6 compatability.
|
||||||
import unittest2 as unittest
|
import unittest2 as unittest
|
||||||
import mock
|
|
||||||
import six
|
|
||||||
|
|
||||||
from timex import expression
|
from timex import expression
|
||||||
|
|
||||||
|
|
||||||
class TestTimestamp(unittest.TestCase):
|
class TestTimestamp(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestTimestamp, self).setUp()
|
super(TestTimestamp, self).setUp()
|
||||||
self.dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
|
self.dt = datetime.datetime(2014, 8, 1, 2, 10, 23, 550)
|
||||||
self.other_dt = datetime.datetime(2014, 8, 7, 2 ,0, 0, 0)
|
self.other_dt = datetime.datetime(2014, 8, 7, 2, 0, 0, 0)
|
||||||
self.timestamp = expression.Timestamp(self.dt)
|
self.timestamp = expression.Timestamp(self.dt)
|
||||||
|
|
||||||
def test_timestamp_properties(self):
|
def test_timestamp_properties(self):
|
||||||
@ -101,14 +115,18 @@ class TestTimestamp(unittest.TestCase):
|
|||||||
self.assertEqual(res.timestamp, expected)
|
self.assertEqual(res.timestamp, expected)
|
||||||
|
|
||||||
expected = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
|
expected = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
|
||||||
res = self.timestamp % expression.Duration(hour=0, minute=0, second=0, microsecond=0)
|
res = self.timestamp % expression.Duration(hour=0, minute=0, second=0,
|
||||||
|
microsecond=0)
|
||||||
self.assertEqual(res.timestamp, expected)
|
self.assertEqual(res.timestamp, expected)
|
||||||
|
|
||||||
def test_handle_ambig_duration(self):
|
def test_handle_ambig_duration(self):
|
||||||
d = expression.Duration(hour=10, unknown=2)
|
d = expression.Duration(hour=10, unknown=2)
|
||||||
self.assertRaises(expression.TimexExpressionError, self.timestamp.__add__, d)
|
self.assertRaises(expression.TimexExpressionError,
|
||||||
self.assertRaises(expression.TimexExpressionError, self.timestamp.__sub__, d)
|
self.timestamp.__add__, d)
|
||||||
self.assertRaises(expression.TimexExpressionError, self.timestamp.__mod__, d)
|
self.assertRaises(expression.TimexExpressionError,
|
||||||
|
self.timestamp.__sub__, d)
|
||||||
|
self.assertRaises(expression.TimexExpressionError,
|
||||||
|
self.timestamp.__mod__, d)
|
||||||
|
|
||||||
def test_total_seconds(self):
|
def test_total_seconds(self):
|
||||||
self.assertFalse(self.timestamp.is_range)
|
self.assertFalse(self.timestamp.is_range)
|
||||||
@ -116,13 +134,12 @@ class TestTimestamp(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestTimeRange(unittest.TestCase):
|
class TestTimeRange(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestTimeRange, self).setUp()
|
super(TestTimeRange, self).setUp()
|
||||||
self.begin_dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
|
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.end_dt = datetime.datetime(2014, 8, 2, 2, 10, 23, 550)
|
||||||
self.middle_dt = datetime.datetime(2014, 8, 1, 17 ,30, 10, 25)
|
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.other_dt = datetime.datetime(2014, 8, 7, 2, 0, 0, 0)
|
||||||
self.timerange = expression.TimeRange(self.begin_dt, self.end_dt)
|
self.timerange = expression.TimeRange(self.begin_dt, self.end_dt)
|
||||||
|
|
||||||
def test_timerange_properties(self):
|
def test_timerange_properties(self):
|
||||||
@ -131,20 +148,20 @@ class TestTimeRange(unittest.TestCase):
|
|||||||
self.assertEqual(self.begin_dt, self.timerange.timestamp)
|
self.assertEqual(self.begin_dt, self.timerange.timestamp)
|
||||||
|
|
||||||
def test_match(self):
|
def test_match(self):
|
||||||
#ranges include the beginning.
|
# ranges include the beginning.
|
||||||
self.assertTrue(self.timerange.match(self.begin_dt))
|
self.assertTrue(self.timerange.match(self.begin_dt))
|
||||||
self.assertTrue(self.timerange.match(self.middle_dt))
|
self.assertTrue(self.timerange.match(self.middle_dt))
|
||||||
|
|
||||||
#ranges *don`t* include the end.
|
# ranges *don`t* include the end.
|
||||||
self.assertFalse(self.timerange.match(self.end_dt))
|
self.assertFalse(self.timerange.match(self.end_dt))
|
||||||
self.assertFalse(self.timerange.match(self.other_dt))
|
self.assertFalse(self.timerange.match(self.other_dt))
|
||||||
|
|
||||||
def test_in(self):
|
def test_in(self):
|
||||||
#ranges include the beginning.
|
# ranges include the beginning.
|
||||||
self.assertTrue(self.begin_dt in self.timerange)
|
self.assertTrue(self.begin_dt in self.timerange)
|
||||||
self.assertTrue(self.middle_dt in self.timerange)
|
self.assertTrue(self.middle_dt in self.timerange)
|
||||||
|
|
||||||
#ranges *don`t* include the end.
|
# ranges *don`t* include the end.
|
||||||
self.assertFalse(self.end_dt in self.timerange)
|
self.assertFalse(self.end_dt in self.timerange)
|
||||||
self.assertFalse(self.other_dt in self.timerange)
|
self.assertFalse(self.other_dt in self.timerange)
|
||||||
|
|
||||||
@ -234,7 +251,6 @@ class TestTimeRange(unittest.TestCase):
|
|||||||
self.assertEqual(res.begin, expected_begin)
|
self.assertEqual(res.begin, expected_begin)
|
||||||
self.assertEqual(res.end, expected_end)
|
self.assertEqual(res.end, expected_end)
|
||||||
|
|
||||||
|
|
||||||
def test_replace(self):
|
def test_replace(self):
|
||||||
expected_begin = datetime.datetime(2014, 8, 1, 6, 10, 23, 550)
|
expected_begin = datetime.datetime(2014, 8, 1, 6, 10, 23, 550)
|
||||||
expected_end = datetime.datetime(2014, 8, 2, 6, 10, 23, 550)
|
expected_end = datetime.datetime(2014, 8, 2, 6, 10, 23, 550)
|
||||||
@ -256,7 +272,8 @@ class TestTimeRange(unittest.TestCase):
|
|||||||
|
|
||||||
expected_begin = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
|
expected_begin = datetime.datetime(2014, 8, 1, 0, 0, 0, 0)
|
||||||
expected_end = datetime.datetime(2014, 8, 2, 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)
|
res = self.timerange % expression.Duration(hour=0, minute=0, second=0,
|
||||||
|
microsecond=0)
|
||||||
self.assertEqual(res.begin, expected_begin)
|
self.assertEqual(res.begin, expected_begin)
|
||||||
self.assertEqual(res.end, expected_end)
|
self.assertEqual(res.end, expected_end)
|
||||||
|
|
||||||
@ -283,17 +300,16 @@ class TestTimeRange(unittest.TestCase):
|
|||||||
|
|
||||||
def test_total_seconds(self):
|
def test_total_seconds(self):
|
||||||
self.assertTrue(self.timerange.is_range)
|
self.assertTrue(self.timerange.is_range)
|
||||||
self.assertEqual(self.timerange.total_seconds(), 24*60*60)
|
self.assertEqual(self.timerange.total_seconds(), 24 * 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
class TestPinnedTimeRange(unittest.TestCase):
|
class TestPinnedTimeRange(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestPinnedTimeRange, self).setUp()
|
super(TestPinnedTimeRange, self).setUp()
|
||||||
self.begin_dt = datetime.datetime(2014, 8, 1, 1, 0, 0, 0)
|
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.end_dt = datetime.datetime(2014, 8, 2, 1, 0, 0, 0)
|
||||||
self.middle_dt = datetime.datetime(2014, 8, 1, 17 ,30, 10, 25)
|
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.other_dt = datetime.datetime(2014, 8, 7, 2, 0, 0, 0)
|
||||||
self.timerange = expression.PinnedTimeRange(self.begin_dt,
|
self.timerange = expression.PinnedTimeRange(self.begin_dt,
|
||||||
self.end_dt,
|
self.end_dt,
|
||||||
self.middle_dt,
|
self.middle_dt,
|
||||||
@ -340,7 +356,6 @@ class TestPinnedTimeRange(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestDuration(unittest.TestCase):
|
class TestDuration(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestDuration, self).setUp()
|
super(TestDuration, self).setUp()
|
||||||
self.second = expression.Duration(second=1)
|
self.second = expression.Duration(second=1)
|
||||||
@ -377,4 +392,3 @@ class TestDuration(unittest.TestCase):
|
|||||||
self.assertEqual(dd[unit], 1)
|
self.assertEqual(dd[unit], 1)
|
||||||
for unit in ('microsecond', 'minute', 'month', 'year', 'unknown'):
|
for unit in ('microsecond', 'minute', 'month', 'year', 'unknown'):
|
||||||
self.assertNotIn(unit, dd)
|
self.assertNotIn(unit, dd)
|
||||||
|
|
||||||
|
@ -1,18 +1,32 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2014 Rackspace Hosting.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
#for Python2.6 compatability.
|
# for Python2.6 compatability.
|
||||||
import unittest2 as unittest
|
import unittest2 as unittest
|
||||||
import mock
|
|
||||||
import six
|
|
||||||
|
|
||||||
import timex
|
import timex
|
||||||
|
|
||||||
|
|
||||||
class TestParse(unittest.TestCase):
|
class TestParse(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestParse, self).setUp()
|
super(TestParse, self).setUp()
|
||||||
self.dt = datetime.datetime(2014, 8, 1, 2 ,10, 23, 550)
|
self.dt = datetime.datetime(2014, 8, 1, 2, 10, 23, 550)
|
||||||
self.other_dt = datetime.datetime(2014, 8, 7, 3, 20, 0, 0)
|
self.other_dt = datetime.datetime(2014, 8, 7, 3, 20, 0, 0)
|
||||||
|
|
||||||
def test_var(self):
|
def test_var(self):
|
||||||
@ -22,21 +36,21 @@ class TestParse(unittest.TestCase):
|
|||||||
self.assertEqual(t.timestamp, self.dt)
|
self.assertEqual(t.timestamp, self.dt)
|
||||||
|
|
||||||
def test_timestamp_add(self):
|
def test_timestamp_add(self):
|
||||||
result = datetime.datetime(2014, 8, 2, 4 ,10, 23, 550)
|
result = datetime.datetime(2014, 8, 2, 4, 10, 23, 550)
|
||||||
exp = timex.parse("$test_thingy + 1d 2h")
|
exp = timex.parse("$test_thingy + 1d 2h")
|
||||||
t = exp(test_thingy=self.dt)
|
t = exp(test_thingy=self.dt)
|
||||||
self.assertFalse(t.is_range)
|
self.assertFalse(t.is_range)
|
||||||
self.assertEqual(t.timestamp, result)
|
self.assertEqual(t.timestamp, result)
|
||||||
|
|
||||||
def test_timestamp_sub(self):
|
def test_timestamp_sub(self):
|
||||||
result = datetime.datetime(2014, 7, 31, 0 ,10, 23, 550)
|
result = datetime.datetime(2014, 7, 31, 0, 10, 23, 550)
|
||||||
exp = timex.parse("$test_thingy - 1d 2h")
|
exp = timex.parse("$test_thingy - 1d 2h")
|
||||||
t = exp(test_thingy=self.dt)
|
t = exp(test_thingy=self.dt)
|
||||||
self.assertFalse(t.is_range)
|
self.assertFalse(t.is_range)
|
||||||
self.assertEqual(t.timestamp, result)
|
self.assertEqual(t.timestamp, result)
|
||||||
|
|
||||||
def test_timestamp_replace(self):
|
def test_timestamp_replace(self):
|
||||||
result = datetime.datetime(2014, 8, 7, 6 ,10, 23, 550)
|
result = datetime.datetime(2014, 8, 7, 6, 10, 23, 550)
|
||||||
exp = timex.parse("$test_thingy @ 7d 6h")
|
exp = timex.parse("$test_thingy @ 7d 6h")
|
||||||
t = exp(test_thingy=self.dt)
|
t = exp(test_thingy=self.dt)
|
||||||
self.assertFalse(t.is_range)
|
self.assertFalse(t.is_range)
|
||||||
@ -50,7 +64,7 @@ class TestParse(unittest.TestCase):
|
|||||||
self.assertEqual(t.end, self.other_dt)
|
self.assertEqual(t.end, self.other_dt)
|
||||||
|
|
||||||
def test_timerange_add(self):
|
def test_timerange_add(self):
|
||||||
result_begin = datetime.datetime(2014, 8, 2, 4 ,10, 23, 550)
|
result_begin = datetime.datetime(2014, 8, 2, 4, 10, 23, 550)
|
||||||
result_end = datetime.datetime(2014, 8, 8, 5, 20, 0, 0)
|
result_end = datetime.datetime(2014, 8, 8, 5, 20, 0, 0)
|
||||||
exp = timex.parse("($test_thingy to $other) + 1d 2h")
|
exp = timex.parse("($test_thingy to $other) + 1d 2h")
|
||||||
t = exp(test_thingy=self.dt, other=self.other_dt)
|
t = exp(test_thingy=self.dt, other=self.other_dt)
|
||||||
@ -59,7 +73,7 @@ class TestParse(unittest.TestCase):
|
|||||||
self.assertEqual(t.end, result_end)
|
self.assertEqual(t.end, result_end)
|
||||||
|
|
||||||
def test_timerange_sub(self):
|
def test_timerange_sub(self):
|
||||||
result_begin = datetime.datetime(2014, 7, 31, 0 ,10, 23, 550)
|
result_begin = datetime.datetime(2014, 7, 31, 0, 10, 23, 550)
|
||||||
result_end = datetime.datetime(2014, 8, 6, 1, 20, 0, 0)
|
result_end = datetime.datetime(2014, 8, 6, 1, 20, 0, 0)
|
||||||
exp = timex.parse("($test_thingy to $other) - 1d 2h")
|
exp = timex.parse("($test_thingy to $other) - 1d 2h")
|
||||||
t = exp(test_thingy=self.dt, other=self.other_dt)
|
t = exp(test_thingy=self.dt, other=self.other_dt)
|
||||||
@ -129,4 +143,3 @@ class TestParse(unittest.TestCase):
|
|||||||
self.assertTrue(t.is_range)
|
self.assertTrue(t.is_range)
|
||||||
self.assertEqual(t.begin, result_begin)
|
self.assertEqual(t.begin, result_begin)
|
||||||
self.assertEqual(t.end, result_end)
|
self.assertEqual(t.end, result_end)
|
||||||
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
from timex.expression import Duration
|
||||||
|
from timex.expression import PinnedTimeRange
|
||||||
|
from timex.expression import Timestamp
|
||||||
|
from timex.expression import TimeRange
|
||||||
|
from timex.expression import TimexExpressionError
|
||||||
|
from timex.expression import TimexParserError
|
||||||
|
from timex.expression import TimexLexerError
|
||||||
|
from timex.expression import TimexError
|
||||||
from timex.parser import parse
|
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'
|
__version__ = '0.10.0'
|
||||||
|
|
||||||
|
@ -1,6 +1,23 @@
|
|||||||
import logging
|
#!/usr/bin/env python
|
||||||
import datetime
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2014 Rackspace Hosting.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
|
||||||
@ -25,7 +42,6 @@ class TimexExpressionError(TimexError):
|
|||||||
|
|
||||||
@six.add_metaclass(abc.ABCMeta)
|
@six.add_metaclass(abc.ABCMeta)
|
||||||
class TimeMatcher(object):
|
class TimeMatcher(object):
|
||||||
|
|
||||||
_allow_ambig_duration = False
|
_allow_ambig_duration = False
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@ -60,11 +76,10 @@ class TimeMatcher(object):
|
|||||||
def _check_duration(self, duration):
|
def _check_duration(self, duration):
|
||||||
if isinstance(duration, Duration):
|
if isinstance(duration, Duration):
|
||||||
if ((duration.ambiguous and self._allow_ambig_duration)
|
if ((duration.ambiguous and self._allow_ambig_duration)
|
||||||
or not duration.ambiguous):
|
or not duration.ambiguous):
|
||||||
return True
|
return True
|
||||||
raise TimexExpressionError("Invalid duration for time operation")
|
raise TimexExpressionError("Invalid duration for time operation")
|
||||||
|
|
||||||
|
|
||||||
def _dt_replace(self, dt, duration):
|
def _dt_replace(self, dt, duration):
|
||||||
return dt.replace(**duration.as_dict)
|
return dt.replace(**duration.as_dict)
|
||||||
|
|
||||||
@ -73,15 +88,16 @@ class TimeMatcher(object):
|
|||||||
months = d.pop('month', 0)
|
months = d.pop('month', 0)
|
||||||
years = d.pop('year', 0)
|
years = d.pop('year', 0)
|
||||||
if d:
|
if d:
|
||||||
delta = datetime.timedelta(**dict((k+"s",val) for k, val in d.items()))
|
delta = datetime.timedelta(
|
||||||
|
**dict((k + "s", val) for k, val in d.items()))
|
||||||
dt = dt + delta
|
dt = dt + delta
|
||||||
if months:
|
if months:
|
||||||
newmonth = dt.month + months
|
newmonth = dt.month + months
|
||||||
years += (newmonth - 1) // 12
|
years += (newmonth - 1) // 12
|
||||||
newmonth = ((newmonth-1) % 12) + 1
|
newmonth = ((newmonth - 1) % 12) + 1
|
||||||
dt = dt.replace(month=newmonth)
|
dt = dt.replace(month=newmonth)
|
||||||
if years:
|
if years:
|
||||||
dt = dt.replace(year=(dt.year+years))
|
dt = dt.replace(year=(dt.year + years))
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
def _dt_sub(self, dt, duration):
|
def _dt_sub(self, dt, duration):
|
||||||
@ -89,21 +105,21 @@ class TimeMatcher(object):
|
|||||||
months = d.pop('month', 0)
|
months = d.pop('month', 0)
|
||||||
years = d.pop('year', 0)
|
years = d.pop('year', 0)
|
||||||
if d:
|
if d:
|
||||||
delta = datetime.timedelta(**dict((k+"s",val) for k, val in d.items()))
|
delta = datetime.timedelta(
|
||||||
|
**dict((k + "s", val) for k, val in d.items()))
|
||||||
dt = dt - delta
|
dt = dt - delta
|
||||||
if months:
|
if months:
|
||||||
newmonth = dt.month - months
|
newmonth = dt.month - months
|
||||||
years -= (newmonth - 1) // 12
|
years -= (newmonth - 1) // 12
|
||||||
newmonth = ((newmonth-1) % 12) + 1
|
newmonth = ((newmonth - 1) % 12) + 1
|
||||||
dt = dt.replace(month=newmonth)
|
dt = dt.replace(month=newmonth)
|
||||||
if years:
|
if years:
|
||||||
dt = dt.replace(year=(dt.year-years))
|
dt = dt.replace(year=(dt.year - years))
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
class Timestamp(TimeMatcher):
|
class Timestamp(TimeMatcher):
|
||||||
"""This is a wrapper on a datetime that has the same
|
"""Wrapper on a datetime with same interface as TimeRange"""
|
||||||
interface as TimeRange"""
|
|
||||||
|
|
||||||
def __init__(self, dt):
|
def __init__(self, dt):
|
||||||
self.timestamp = dt
|
self.timestamp = dt
|
||||||
@ -136,7 +152,6 @@ class Timestamp(TimeMatcher):
|
|||||||
|
|
||||||
|
|
||||||
class TimeRange(TimeMatcher):
|
class TimeRange(TimeMatcher):
|
||||||
|
|
||||||
_allow_ambig_duration = True
|
_allow_ambig_duration = True
|
||||||
|
|
||||||
def __init__(self, begin, end):
|
def __init__(self, begin, end):
|
||||||
@ -149,7 +164,8 @@ class TimeRange(TimeMatcher):
|
|||||||
|
|
||||||
def total_seconds(self):
|
def total_seconds(self):
|
||||||
delta = self.end - self.begin
|
delta = self.end - self.begin
|
||||||
return delta.seconds + (delta.days * 24 * 3600) + (delta.microseconds * 1e-6)
|
return (delta.seconds + (delta.days * 24 * 3600) +
|
||||||
|
(delta.microseconds * 1e-6))
|
||||||
|
|
||||||
def __nonzero__(self):
|
def __nonzero__(self):
|
||||||
return self.total_seconds() > 0
|
return self.total_seconds() > 0
|
||||||
@ -159,7 +175,10 @@ class TimeRange(TimeMatcher):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def match(self, dt):
|
def match(self, dt):
|
||||||
"""TimeRanges match datetimes from begin (inclusive) to end (exclusive)"""
|
"""Match datetimes
|
||||||
|
|
||||||
|
TimeRanges match datetimes from begin (inclusive) to end (exclusive)
|
||||||
|
"""
|
||||||
return dt >= self.begin and dt < self.end
|
return dt >= self.begin and dt < self.end
|
||||||
|
|
||||||
def __add__(self, other):
|
def __add__(self, other):
|
||||||
@ -236,11 +255,11 @@ class PinnedTimeRange(TimeRange):
|
|||||||
return self.time_range
|
return self.time_range
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "PinnedTimeRange from %r to %r. Pinned to %s(%r)" % (self.begin, self.end, self.unit, self.pinned_to)
|
return ("PinnedTimeRange from %r to %r. Pinned to %s(%r)"
|
||||||
|
% (self.begin, self.end, self.unit, self.pinned_to))
|
||||||
|
|
||||||
|
|
||||||
class Environment(dict):
|
class Environment(dict):
|
||||||
|
|
||||||
def time_func_hour(self, timestamp):
|
def time_func_hour(self, timestamp):
|
||||||
dt = timestamp.timestamp
|
dt = timestamp.timestamp
|
||||||
begin = dt.replace(minute=0, second=0, microsecond=0)
|
begin = dt.replace(minute=0, second=0, microsecond=0)
|
||||||
@ -261,20 +280,20 @@ class Environment(dict):
|
|||||||
|
|
||||||
def time_func_year(self, timestamp):
|
def time_func_year(self, timestamp):
|
||||||
dt = timestamp.timestamp
|
dt = timestamp.timestamp
|
||||||
begin = dt.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
begin = dt.replace(month=1, day=1, hour=0, minute=0, second=0,
|
||||||
|
microsecond=0)
|
||||||
end = Timestamp(begin) + Duration(year=1)
|
end = Timestamp(begin) + Duration(year=1)
|
||||||
return PinnedTimeRange(begin, end.timestamp, dt, 'year')
|
return PinnedTimeRange(begin, end.timestamp, dt, 'year')
|
||||||
|
|
||||||
|
|
||||||
@six.add_metaclass(abc.ABCMeta)
|
@six.add_metaclass(abc.ABCMeta)
|
||||||
class TimeExpression(object):
|
class TimeExpression(object):
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def apply(self, env):
|
def apply(self, env):
|
||||||
"""Apply the expression to a given set of arguments.
|
"""Apply the expression to a given set of arguments.
|
||||||
|
|
||||||
:param env: a dictionary-like object. expression functions should be methods
|
:param env: a dictionary-like object. expression functions should
|
||||||
on this object with names beginning with 'time_func_'
|
be methods on this object with names beginning with 'time_func_'
|
||||||
returns: TimeMatcher instance
|
returns: TimeMatcher instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -308,7 +327,8 @@ class TimeRangeFunction(TimeRangeExpression):
|
|||||||
self.expr = expr
|
self.expr = expr
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '%s %s(%r)' % (self.__class__.__name__, self.func_name, self.expr)
|
return ('%s %s(%r)'
|
||||||
|
% (self.__class__.__name__, self.func_name, self.expr))
|
||||||
|
|
||||||
def apply(self, env):
|
def apply(self, env):
|
||||||
arg = self.expr.apply(env)
|
arg = self.expr.apply(env)
|
||||||
@ -341,7 +361,8 @@ class Operation(TimeExpression):
|
|||||||
self.duration = duration
|
self.duration = duration
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '%s(%r, %r)' % (self.__class__.__name__, self.expr, self.duration)
|
return ('%s(%r, %r)'
|
||||||
|
% (self.__class__.__name__, self.expr, self.duration))
|
||||||
|
|
||||||
|
|
||||||
class Replace(Operation):
|
class Replace(Operation):
|
||||||
@ -366,11 +387,10 @@ class Minus(Operation):
|
|||||||
|
|
||||||
|
|
||||||
class Duration(object):
|
class Duration(object):
|
||||||
|
UNIT_SIZES = {'year': 365 * 24 * 60 * 60,
|
||||||
UNIT_SIZES = {'year': 365*24*60*60,
|
'month': 28 * 24 * 60 * 60,
|
||||||
'month': 28*24*60*60,
|
'day': 24 * 60 * 60,
|
||||||
'day': 24*60*60,
|
'hour': 60 * 60,
|
||||||
'hour': 60*60,
|
|
||||||
'minute': 60,
|
'minute': 60,
|
||||||
'second': 1,
|
'second': 1,
|
||||||
'microsecond': 1e-6}
|
'microsecond': 1e-6}
|
||||||
@ -423,7 +443,7 @@ class Duration(object):
|
|||||||
elif d >= self.UNIT_SIZES['minute']:
|
elif d >= self.UNIT_SIZES['minute']:
|
||||||
unit = 'second'
|
unit = 'second'
|
||||||
else:
|
else:
|
||||||
unit = microsecond
|
unit = 'microsecond'
|
||||||
vals = self.as_dict
|
vals = self.as_dict
|
||||||
del vals['unknown']
|
del vals['unknown']
|
||||||
if unit in vals:
|
if unit in vals:
|
||||||
@ -476,4 +496,3 @@ class Duration(object):
|
|||||||
if our_val != other_val:
|
if our_val != other_val:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -1,5 +1,22 @@
|
|||||||
import sys
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2014 Rackspace Hosting.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
import ply.lex
|
import ply.lex
|
||||||
|
|
||||||
@ -16,8 +33,8 @@ class TimexLexer(object):
|
|||||||
self.debug = debug
|
self.debug = debug
|
||||||
if not self.__doc__:
|
if not self.__doc__:
|
||||||
raise TimexLexerError("Docstring information is missing. "
|
raise TimexLexerError("Docstring information is missing. "
|
||||||
"Timex uses PLY which requires docstrings for "
|
"Timex uses PLY which requires "
|
||||||
"configuration.")
|
"docstrings for configuration.")
|
||||||
self.lexer = ply.lex.lex(module=self,
|
self.lexer = ply.lex.lex(module=self,
|
||||||
debug=self.debug,
|
debug=self.debug,
|
||||||
errorlog=logger)
|
errorlog=logger)
|
||||||
@ -36,19 +53,20 @@ class TimexLexer(object):
|
|||||||
token.col = token.lexpos - self.latest_newline
|
token.col = token.lexpos - self.latest_newline
|
||||||
return token
|
return token
|
||||||
|
|
||||||
reserved_words = { 'to' : 'TO',
|
reserved_words = {
|
||||||
'us' : 'MICROSECOND',
|
'to': 'TO',
|
||||||
's' : 'SECOND',
|
'us': 'MICROSECOND',
|
||||||
'sec' : 'SECOND',
|
's': 'SECOND',
|
||||||
'm' : 'MINUTE',
|
'sec': 'SECOND',
|
||||||
'min' : 'MINUTE',
|
'm': 'MINUTE',
|
||||||
'h' : 'HOUR',
|
'min': 'MINUTE',
|
||||||
'hr' : 'HOUR',
|
'h': 'HOUR',
|
||||||
'd' : 'DAY',
|
'hr': 'HOUR',
|
||||||
'mo' : 'MONTH',
|
'd': 'DAY',
|
||||||
'y' : 'YEAR',
|
'mo': 'MONTH',
|
||||||
'yr' : 'YEAR',
|
'y': 'YEAR',
|
||||||
}
|
'yr': 'YEAR',
|
||||||
|
}
|
||||||
|
|
||||||
tokens = ('NUMBER',
|
tokens = ('NUMBER',
|
||||||
'PLUS',
|
'PLUS',
|
||||||
@ -59,12 +77,12 @@ class TimexLexer(object):
|
|||||||
'VAR',
|
'VAR',
|
||||||
'IDENTIFIER') + tuple(set(reserved_words.values()))
|
'IDENTIFIER') + tuple(set(reserved_words.values()))
|
||||||
|
|
||||||
t_PLUS = r'\+'
|
t_PLUS = r'\+'
|
||||||
t_MINUS = r'-'
|
t_MINUS = r'-'
|
||||||
t_REPLACE = r'@'
|
t_REPLACE = r'@'
|
||||||
t_VAR = r'\$'
|
t_VAR = r'\$'
|
||||||
t_LPAREN = r'\('
|
t_LPAREN = r'\('
|
||||||
t_RPAREN = r'\)'
|
t_RPAREN = r'\)'
|
||||||
|
|
||||||
def t_IDENTIFIER(self, t):
|
def t_IDENTIFIER(self, t):
|
||||||
r'[a-zA-Z_][a-zA-Z0-9_]*'
|
r'[a-zA-Z_][a-zA-Z0-9_]*'
|
||||||
@ -76,13 +94,12 @@ class TimexLexer(object):
|
|||||||
t.value = int(t.value)
|
t.value = int(t.value)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def t_newline(self, t):
|
def t_newline(self, t):
|
||||||
r'\n+'
|
r'\n+'
|
||||||
t.lexer.lineno += len(t.value)
|
t.lexer.lineno += len(t.value)
|
||||||
self.latest_newline = t.lexpos
|
self.latest_newline = t.lexpos
|
||||||
|
|
||||||
t_ignore = ' \t'
|
t_ignore = ' \t'
|
||||||
|
|
||||||
def t_error(self, t):
|
def t_error(self, t):
|
||||||
raise TimexLexerError('Error on line %s, col %s: Unexpected character:'
|
raise TimexLexerError('Error on line %s, col %s: Unexpected character:'
|
||||||
@ -99,4 +116,3 @@ if __name__ == '__main__':
|
|||||||
while token:
|
while token:
|
||||||
print('%-20s%s' % (token.value, token.type))
|
print('%-20s%s' % (token.value, token.type))
|
||||||
token = lexer.token()
|
token = lexer.token()
|
||||||
|
|
||||||
|
@ -1,14 +1,34 @@
|
|||||||
import sys, os
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2014 Rackspace Hosting.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import ply.yacc
|
import ply.yacc
|
||||||
|
|
||||||
from timex.lexer import TimexLexer
|
from timex.expression import Duration
|
||||||
|
from timex.expression import Minus
|
||||||
|
from timex.expression import Plus
|
||||||
|
from timex.expression import Replace
|
||||||
|
from timex.expression import TimeRangeExpression
|
||||||
|
from timex.expression import TimeRangeFunction
|
||||||
from timex.expression import TimexParserError
|
from timex.expression import TimexParserError
|
||||||
from timex.expression import Replace, Plus, Minus
|
from timex.expression import Variable
|
||||||
from timex.expression import Duration, Variable
|
from timex.lexer import TimexLexer
|
||||||
from timex.expression import TimeRangeFunction, TimeRangeExpression
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -53,7 +73,6 @@ unit : SECOND
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -62,7 +81,7 @@ def parse(string):
|
|||||||
|
|
||||||
|
|
||||||
class TimexParser(object):
|
class TimexParser(object):
|
||||||
""" LALR parser for time expression mini-language."""
|
"""LALR parser for time expression mini-language."""
|
||||||
tokens = TimexLexer.tokens
|
tokens = TimexLexer.tokens
|
||||||
|
|
||||||
def __init__(self, debug=False, lexer_class=None, start='time_expression'):
|
def __init__(self, debug=False, lexer_class=None, start='time_expression'):
|
||||||
@ -70,15 +89,15 @@ class TimexParser(object):
|
|||||||
self.start = start
|
self.start = start
|
||||||
if not self.__doc__:
|
if not self.__doc__:
|
||||||
raise TimexParserError("Docstring information is missing. "
|
raise TimexParserError("Docstring information is missing. "
|
||||||
"Timex uses PLY which requires docstrings for "
|
"Timex uses PLY which requires "
|
||||||
"configuration.")
|
"docstrings for configuration.")
|
||||||
self.lexer_class = lexer_class or TimexLexer
|
self.lexer_class = lexer_class or TimexLexer
|
||||||
|
|
||||||
def _parse_table(self):
|
def _parse_table(self):
|
||||||
tabdir = os.path.dirname(__file__)
|
tabdir = os.path.dirname(__file__)
|
||||||
try:
|
try:
|
||||||
module_name = os.path.splitext(os.path.split(__file__)[1])[0]
|
module_name = os.path.splitext(os.path.split(__file__)[1])[0]
|
||||||
except:
|
except Exception:
|
||||||
module_name = __name__
|
module_name = __name__
|
||||||
table_module = '_'.join([module_name, self.start, 'parsetab'])
|
table_module = '_'.join([module_name, self.start, 'parsetab'])
|
||||||
return (tabdir, table_module)
|
return (tabdir, table_module)
|
||||||
@ -89,11 +108,11 @@ class TimexParser(object):
|
|||||||
tabdir, table_module = self._parse_table()
|
tabdir, table_module = self._parse_table()
|
||||||
parser = ply.yacc.yacc(module=self,
|
parser = ply.yacc.yacc(module=self,
|
||||||
debug=self.debug,
|
debug=self.debug,
|
||||||
tabmodule = table_module,
|
tabmodule=table_module,
|
||||||
outputdir = tabdir,
|
outputdir=tabdir,
|
||||||
write_tables=0,
|
write_tables=0,
|
||||||
start = self.start,
|
start=self.start,
|
||||||
errorlog = logger)
|
errorlog=logger)
|
||||||
|
|
||||||
return parser.parse(string, lexer=lexer)
|
return parser.parse(string, lexer=lexer)
|
||||||
|
|
||||||
@ -106,7 +125,7 @@ class TimexParser(object):
|
|||||||
|
|
||||||
def p_error(self, t):
|
def p_error(self, t):
|
||||||
raise TimexParserError('Parse error at %s:%s near token %s (%s)' %
|
raise TimexParserError('Parse error at %s:%s near token %s (%s)' %
|
||||||
(t.lineno, t.col, t.value, t.type))
|
(t.lineno, t.col, t.value, t.type))
|
||||||
|
|
||||||
def p_time_expression(self, p):
|
def p_time_expression(self, p):
|
||||||
"""time_expression : timerange_expression
|
"""time_expression : timerange_expression
|
||||||
@ -189,7 +208,8 @@ class TimexParser(object):
|
|||||||
| HOUR
|
| HOUR
|
||||||
| DAY
|
| DAY
|
||||||
| MONTH
|
| MONTH
|
||||||
| YEAR"""
|
| YEAR
|
||||||
|
"""
|
||||||
unit = TimexLexer.reserved_words[p[1]]
|
unit = TimexLexer.reserved_words[p[1]]
|
||||||
unit = unit.lower()
|
unit = unit.lower()
|
||||||
p[0] = unit
|
p[0] = unit
|
||||||
|
10
tox.ini
10
tox.ini
@ -1,5 +1,5 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py26,py27
|
envlist = py26,py27,pep8
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
@ -13,3 +13,11 @@ commands =
|
|||||||
|
|
||||||
sitepackages = False
|
sitepackages = False
|
||||||
|
|
||||||
|
[testenv:pep8]
|
||||||
|
commands =
|
||||||
|
flake8
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
ignore = H405
|
||||||
|
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,timex/__init__.py
|
||||||
|
show-source = True
|
||||||
|
Loading…
Reference in New Issue
Block a user