diff --git a/.vscode/settings.json b/.vscode/settings.json index 76d401778..a3f36eeff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,9 +15,6 @@ "**/bazel-*/**": true }, "python.formatting.provider": "black", - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" - }, "editor.tabSize": 4, "editor.formatOnSave": true, "python.linting.flake8Enabled": false, diff --git a/docs/src/reference/index.md b/docs/src/reference/index.md index 830614fa1..5bb03996a 100644 --- a/docs/src/reference/index.md +++ b/docs/src/reference/index.md @@ -45,6 +45,7 @@ Check the index on the left for a more detailed description of any symbol. | [`tp.cast()`][temporian.cast] | Casts the dtype of features. | | [`tp.drop_index()`][temporian.drop_index] | Removes indexes from a [`Node`][temporian.Node]. | | [`tp.end()`][temporian.end] | Generates a single timestamp at the end of the input. | +| [`tp.enumerate()`][temporian.enumerate] | Creates an ordinal feature enumerating the events according to their timestamp. | | [`tp.filter()`][temporian.filter] | Filters out events in a [`Node`][temporian.Node] for which a condition is false. | | [`tp.glue()`][temporian.glue] | Concatenates [`Nodes`][temporian.Node] with the same sampling. | | [`tp.lag()`][temporian.lag] | Adds a delay to a [`Node`][temporian.Node]'s timestamps. | @@ -57,6 +58,7 @@ Check the index on the left for a more detailed description of any symbol. | [`tp.set_index()`][temporian.set_index] | Replaces the indexes in a [`Node`][temporian.Node]. | | [`tp.since_last()`][temporian.since_last] | Computes the amount of time since the last distinct timestamp. | | [`tp.tick()`][temporian.tick] | Generates timestamps at regular intervals in the range of a guide. | +| [`tp.timestamps()`][temporian.timestamps] | Creates a feature from the events timestamps (`float64`). | | [`tp.unique_timestamps()`][temporian.unique_timestamps] | Removes events with duplicated timestamps from a [`Node`][temporian.Node]. | ### Binary operators diff --git a/docs/src/reference/temporian/operators/enumerate.md b/docs/src/reference/temporian/operators/enumerate.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/src/reference/temporian/operators/timestamps.md b/docs/src/reference/temporian/operators/timestamps.md new file mode 100644 index 000000000..e69de29bb diff --git a/temporian/__init__.py b/temporian/__init__.py index 88936300b..e6af89755 100644 --- a/temporian/__init__.py +++ b/temporian/__init__.py @@ -99,6 +99,8 @@ from temporian.core.operators.since_last import since_last from temporian.core.operators.tick import tick from temporian.core.operators.unique_timestamps import unique_timestamps +from temporian.core.operators.timestamps import timestamps +from temporian.core.operators.enumerate import enumerate # Binary operators from temporian.core.operators.binary.arithmetic import add diff --git a/temporian/core/operators/BUILD b/temporian/core/operators/BUILD index f8aa72a64..077a11641 100644 --- a/temporian/core/operators/BUILD +++ b/temporian/core/operators/BUILD @@ -269,3 +269,30 @@ py_library( "//temporian/proto:core_py_proto", ], ) + +py_library( + name = "timestamps", + srcs = ["timestamps.py"], + srcs_version = "PY3", + deps = [ + ":base", + "//temporian/core:operator_lib", + "//temporian/core/data:node", + "//temporian/core/data:schema", + "//temporian/proto:core_py_proto", + ], +) + +py_library( + name = "enumerate", + srcs = ["enumerate.py"], + srcs_version = "PY3", + deps = [ + ":base", + "//temporian/core:operator_lib", + "//temporian/core/data:node", + "//temporian/core/data:schema", + "//temporian/proto:core_py_proto", + ], +) + \ No newline at end of file diff --git a/temporian/core/operators/enumerate.py b/temporian/core/operators/enumerate.py new file mode 100644 index 000000000..0591b47d3 --- /dev/null +++ b/temporian/core/operators/enumerate.py @@ -0,0 +1,93 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. + + +"""Enumerate operator class and public API function definitions.""" + +from temporian.core import operator_lib +from temporian.core.data.node import ( + Node, + create_node_new_features_existing_sampling, +) +from temporian.core.compilation import compile +from temporian.core.operators.base import Operator +from temporian.proto import core_pb2 as pb +from temporian.core.data import dtype + + +class Enumerate(Operator): + def __init__(self, input: Node): + super().__init__() + + self.add_input("input", input) + + self.add_output( + "output", + create_node_new_features_existing_sampling( + features=[("enumerate", dtype.int64)], + sampling_node=input, + creator=self, + ), + ) + + self.check() + + @classmethod + def build_op_definition(cls) -> pb.OperatorDef: + return pb.OperatorDef( + key="ENUMERATE", + attributes=[], + inputs=[pb.OperatorDef.Input(key="input")], + outputs=[pb.OperatorDef.Output(key="output")], + ) + + +operator_lib.register_operator(Enumerate) + + +@compile +def enumerate(input: Node) -> Node: + """Create an `int64` feature with the ordinal position of each event. + + Each index is enumerated independently. + + Usage: + ```python + >>> evset = tp.event_set( + ... timestamps=[-1, 2, 3, 5, 0], + ... features={"a": ["A", "A", "A", "A", "B"]}, + ... indexes=["a"], + ... ) + >>> tp.enumerate(evset.node()).run(evset) + indexes: [('a', str_)] + features: [('enumerate', int64)] + events: + a=A (4 events): + timestamps: [-1. 2. 3. 5.] + 'enumerate': [0 1 2 3] + a=B (1 events): + timestamps: [0.] + 'enumerate': [0] + ... + + ``` + + Args: + input: Node to enumerate. + + Returns: + Single feature with each event's ordinal position in index. + """ + + return Enumerate(input=input).outputs["output"] diff --git a/temporian/core/operators/timestamps.py b/temporian/core/operators/timestamps.py new file mode 100644 index 000000000..777f82cf5 --- /dev/null +++ b/temporian/core/operators/timestamps.py @@ -0,0 +1,122 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. + + +"""Timestamps operator class and public API function definitions.""" + +from temporian.core import operator_lib +from temporian.core.data.node import ( + Node, + create_node_new_features_existing_sampling, +) +from temporian.core.compilation import compile +from temporian.core.operators.base import Operator +from temporian.proto import core_pb2 as pb +from temporian.core.data import dtype + + +class Timestamps(Operator): + def __init__(self, input: Node): + super().__init__() + + self.add_input("input", input) + + self.add_output( + "output", + create_node_new_features_existing_sampling( + features=[("timestamps", dtype.float64)], + sampling_node=input, + creator=self, + ), + ) + + self.check() + + @classmethod + def build_op_definition(cls) -> pb.OperatorDef: + return pb.OperatorDef( + key="TIMESTAMPS", + attributes=[], + inputs=[pb.OperatorDef.Input(key="input")], + outputs=[pb.OperatorDef.Output(key="output")], + ) + + +operator_lib.register_operator(Timestamps) + + +@compile +def timestamps(input: Node) -> Node: + """Converts the event timestamps into a `float64` feature. + + Features in the input node are ignored, only the timestamps are used. + Datetime timestamps are converted to unix timestamps. + + Integer timestamps example: + ```python + >>> from datetime import datetime + >>> evset = tp.event_set( + ... timestamps=[1, 2, 3, 5], + ... ) + >>> tp.timestamps(evset.node()).run(evset) + indexes: [] + features: [('timestamps', float64)] + events: + (4 events): + timestamps: [1. 2. 3. 5.] + 'timestamps': [1. 2. 3. 5.] + ... + + ``` + + Unix timestamps and filter example: + ```python + >>> from datetime import datetime + >>> evset = tp.event_set( + ... timestamps=[datetime(1970,1,1,0,0,30), datetime(2023,1,1,1,0,0)], + ... ) + >>> node = evset.node() + >>> tstamps = tp.timestamps(node) + + >>> # Filter using the timestamps + >>> old_times = tp.filter( + ... tstamps, tstamps < datetime(2020, 1, 1).timestamp() + ... ) + + >>> # Operate like any other feature + >>> multiply = old_times * 5 + >>> result = tp.glue( + ... tp.rename(old_times, 'filtered'), + ... tp.rename(multiply, 'multiplied') + ... ) + >>> result.run(evset) + indexes: [] + features: [('filtered', float64), ('multiplied', float64)] + events: + (1 events): + timestamps: [30.] + 'filtered': [30.] + 'multiplied': [150.] + ... + + ``` + + Args: + input: Node to get the timestamps from. + + Returns: + Single feature `timestamps` with each event's timestamp value. + """ + + return Timestamps(input=input).outputs["output"] diff --git a/temporian/core/test/registered_operators_test.py b/temporian/core/test/registered_operators_test.py index ec28e0fcc..d49652b1d 100644 --- a/temporian/core/test/registered_operators_test.py +++ b/temporian/core/test/registered_operators_test.py @@ -45,6 +45,7 @@ def test_base(self): "DIVISION_SCALAR", "DROP_INDEX", "END", + "ENUMERATE", "EQUAL", "EQUAL_SCALAR", "FILTER", @@ -89,6 +90,7 @@ def test_base(self): "SUBTRACTION", "SUBTRACTION_SCALAR", "TICK", + "TIMESTAMPS", "UNIQUE_TIMESTAMPS", "XOR", ] diff --git a/temporian/implementation/numpy/operators/BUILD b/temporian/implementation/numpy/operators/BUILD index bcd051254..2a4e17ebb 100644 --- a/temporian/implementation/numpy/operators/BUILD +++ b/temporian/implementation/numpy/operators/BUILD @@ -16,6 +16,7 @@ py_library( ":cast", ":drop_index", ":end", + ":enumerate", ":filter", ":glue", ":lag", @@ -27,6 +28,7 @@ py_library( ":select", ":since_last", ":tick", + ":timestamps", ":unary", ":unique_timestamps", "//temporian/implementation/numpy/operators/binary:arithmetic", @@ -292,3 +294,33 @@ py_library( "//temporian/implementation/numpy/data:event_set", ], ) + +py_library( + name = "timestamps", + srcs = ["timestamps.py"], + srcs_version = "PY3", + deps = [ + # already_there/numpy + ":base", + "//temporian/core/data:duration_utils", + "//temporian/core/operators:timestamps", + "//temporian/implementation/numpy:implementation_lib", + "//temporian/implementation/numpy:utils", + "//temporian/implementation/numpy/data:event_set", + ], +) + +py_library( + name = "enumerate", + srcs = ["enumerate.py"], + srcs_version = "PY3", + deps = [ + # already_there/numpy + ":base", + "//temporian/core/data:duration_utils", + "//temporian/core/operators:enumerate", + "//temporian/implementation/numpy:implementation_lib", + "//temporian/implementation/numpy:utils", + "//temporian/implementation/numpy/data:event_set", + ], +) diff --git a/temporian/implementation/numpy/operators/__init__.py b/temporian/implementation/numpy/operators/__init__.py index 09cce5281..0609cccf4 100644 --- a/temporian/implementation/numpy/operators/__init__.py +++ b/temporian/implementation/numpy/operators/__init__.py @@ -57,3 +57,5 @@ from temporian.implementation.numpy.operators import begin from temporian.implementation.numpy.operators import end from temporian.implementation.numpy.operators import tick +from temporian.implementation.numpy.operators import timestamps +from temporian.implementation.numpy.operators import enumerate diff --git a/temporian/implementation/numpy/operators/enumerate.py b/temporian/implementation/numpy/operators/enumerate.py new file mode 100644 index 000000000..aab0da615 --- /dev/null +++ b/temporian/implementation/numpy/operators/enumerate.py @@ -0,0 +1,57 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. + + +"""Implementation for the Enumerate operator.""" + + +from typing import Dict +import numpy as np + +from temporian.implementation.numpy.data.event_set import IndexData, EventSet +from temporian.core.operators.enumerate import Enumerate +from temporian.implementation.numpy import implementation_lib +from temporian.implementation.numpy.operators.base import OperatorImplementation + + +class EnumerateNumpyImplementation(OperatorImplementation): + def __init__(self, operator: Enumerate) -> None: + assert isinstance(operator, Enumerate) + super().__init__(operator) + + def __call__(self, input: EventSet) -> Dict[str, EventSet]: + assert isinstance(self.operator, Enumerate) + + output_schema = self.output_schema("output") + + # Create output EventSet + output_evset = EventSet(data={}, schema=output_schema) + + # Fill output EventSet's data + for index_key, index_data in input.data.items(): + output_evset.set_index_value( + index_key, + IndexData( + [np.arange(len(index_data.timestamps), dtype=np.int64)], + index_data.timestamps, + schema=output_schema, + ), + ) + + return {"output": output_evset} + + +implementation_lib.register_operator_implementation( + Enumerate, EnumerateNumpyImplementation +) diff --git a/temporian/implementation/numpy/operators/test/BUILD b/temporian/implementation/numpy/operators/test/BUILD index d8d3c286e..7284a8c75 100644 --- a/temporian/implementation/numpy/operators/test/BUILD +++ b/temporian/implementation/numpy/operators/test/BUILD @@ -671,3 +671,36 @@ py_test( "//temporian/implementation/numpy/operators:tick", ], ) + +py_test( + name = "timestamps_test", + srcs = ["timestamps_test.py"], + srcs_version = "PY3", + deps = [ + # already_there/absl/testing:absltest + ":test_util", + "//temporian/core/data:dtype", + "//temporian/core/data:node", + "//temporian/core/data:schema", + "//temporian/implementation/numpy/data:io", + "//temporian/core/operators:timestamps", + "//temporian/implementation/numpy/operators:timestamps", + ], +) + +py_test( + name = "enumerate_test", + srcs = ["enumerate_test.py"], + srcs_version = "PY3", + deps = [ + # already_there/absl/testing:absltest + ":test_util", + "//temporian/core/data:dtype", + "//temporian/core/data:node", + "//temporian/core/data:schema", + "//temporian/implementation/numpy/data:io", + "//temporian/core/operators:enumerate", + "//temporian/implementation/numpy/operators:enumerate", + ], +) + \ No newline at end of file diff --git a/temporian/implementation/numpy/operators/test/enumerate_test.py b/temporian/implementation/numpy/operators/test/enumerate_test.py new file mode 100644 index 000000000..4c16c0963 --- /dev/null +++ b/temporian/implementation/numpy/operators/test/enumerate_test.py @@ -0,0 +1,65 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. + + +from absl.testing import absltest + +import numpy as np +from temporian.core.operators.enumerate import Enumerate +from temporian.implementation.numpy.data.io import event_set +from temporian.implementation.numpy.operators.enumerate import ( + EnumerateNumpyImplementation, +) +from temporian.implementation.numpy.operators.test.test_util import ( + assertEqualEventSet, + testOperatorAndImp, +) + + +class EnumerateOperatorTest(absltest.TestCase): + def setUp(self): + pass + + def test_base(self): + evset = event_set( + timestamps=[1, 2, 3, 4, 0, 1], + features={ + "a": [1.0, 2.0, 3.0, 4.0, 0.0, 1.0], + "b": [5, 6, 7, 8, 1, 2], + "c": ["A", "A", "A", "A", "B", "B"], + }, + indexes=["c"], + ) + node = evset.node() + + expected_output = event_set( + timestamps=[1, 2, 3, 4, 0, 1], + features={ + "enumerate": [0, 1, 2, 3, 0, 1], + "c": ["A", "A", "A", "A", "B", "B"], + }, + indexes=["c"], + ) + + # Run op + op = Enumerate(input=node) + instance = EnumerateNumpyImplementation(op) + testOperatorAndImp(self, op, instance) + output = instance.call(input=evset)["output"] + + assertEqualEventSet(self, output, expected_output) + + +if __name__ == "__main__": + absltest.main() diff --git a/temporian/implementation/numpy/operators/test/timestamps_test.py b/temporian/implementation/numpy/operators/test/timestamps_test.py new file mode 100644 index 000000000..12b48d987 --- /dev/null +++ b/temporian/implementation/numpy/operators/test/timestamps_test.py @@ -0,0 +1,96 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. +from datetime import datetime, timezone + +from absl.testing import absltest + +import numpy as np +from temporian.core.operators.timestamps import Timestamps +from temporian.implementation.numpy.data.io import event_set +from temporian.implementation.numpy.operators.timestamps import ( + TimestampsNumpyImplementation, +) +from temporian.implementation.numpy.operators.test.test_util import ( + assertEqualEventSet, + testOperatorAndImp, +) + + +class TimestampsOperatorTest(absltest.TestCase): + def setUp(self): + pass + + def test_base(self): + evset = event_set( + timestamps=[-1, 1, 2, 3, 4, 10], + features={ + "a": [np.nan, 1.0, 2.0, 3.0, 4.0, np.nan], + "b": ["A", "A", "B", "B", "C", "C"], + }, + indexes=["b"], + ) + node = evset.node() + + expected_output = event_set( + timestamps=[-1, 1, 2, 3, 4, 10], + features={ + "timestamps": [-1.0, 1.0, 2.0, 3.0, 4.0, 10.0], + "b": ["A", "A", "B", "B", "C", "C"], + }, + indexes=["b"], + ) + + # Run op + op = Timestamps(input=node) + instance = TimestampsNumpyImplementation(op) + testOperatorAndImp(self, op, instance) + output = instance.call(input=evset)["output"] + + assertEqualEventSet(self, output, expected_output) + + def test_unix_timestamps(self): + t0 = 1688156488.0 + timestamps = [t0, t0 + 24 * 3600 * 5, t0 + 0.4] + dtimes = [datetime.fromtimestamp(t, timezone.utc) for t in timestamps] + + evset = event_set( + timestamps=dtimes, + features={ + "b": ["A", "A", "B"], + }, + indexes=["b"], + ) + node = evset.node() + + expected_output = event_set( + timestamps=timestamps, + features={ + "timestamps": timestamps, + "b": ["A", "A", "B"], + }, + indexes=["b"], + is_unix_timestamp=True, + ) + + # Run op + op = Timestamps(input=node) + instance = TimestampsNumpyImplementation(op) + testOperatorAndImp(self, op, instance) + output = instance.call(input=evset)["output"] + + assertEqualEventSet(self, output, expected_output) + + +if __name__ == "__main__": + absltest.main() diff --git a/temporian/implementation/numpy/operators/timestamps.py b/temporian/implementation/numpy/operators/timestamps.py new file mode 100644 index 000000000..9fb3160b1 --- /dev/null +++ b/temporian/implementation/numpy/operators/timestamps.py @@ -0,0 +1,57 @@ +# Copyright 2021 Google LLC. +# +# 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 +# +# https://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. + + +"""Implementation for the Timestamps operator.""" + + +from typing import Dict +import numpy as np + +from temporian.implementation.numpy.data.event_set import IndexData, EventSet +from temporian.core.operators.timestamps import Timestamps +from temporian.implementation.numpy import implementation_lib +from temporian.implementation.numpy.operators.base import OperatorImplementation + + +class TimestampsNumpyImplementation(OperatorImplementation): + def __init__(self, operator: Timestamps) -> None: + assert isinstance(operator, Timestamps) + super().__init__(operator) + + def __call__(self, input: EventSet) -> Dict[str, EventSet]: + assert isinstance(self.operator, Timestamps) + + output_schema = self.output_schema("output") + + # Create output EventSet + output_evset = EventSet(data={}, schema=output_schema) + + # Fill output EventSet's data + for index_key, index_data in input.data.items(): + output_evset.set_index_value( + index_key, + IndexData( + [index_data.timestamps], + index_data.timestamps, + schema=output_schema, + ), + ) + + return {"output": output_evset} + + +implementation_lib.register_operator_implementation( + Timestamps, TimestampsNumpyImplementation +) diff --git a/temporian/implementation/numpy/test/registered_operators_test.py b/temporian/implementation/numpy/test/registered_operators_test.py index 53f3a6413..81d89f252 100644 --- a/temporian/implementation/numpy/test/registered_operators_test.py +++ b/temporian/implementation/numpy/test/registered_operators_test.py @@ -43,6 +43,7 @@ def test_base(self): "DIVISION_SCALAR", "DROP_INDEX", "END", + "ENUMERATE", "EQUAL", "EQUAL_SCALAR", "FILTER", @@ -87,6 +88,7 @@ def test_base(self): "SUBTRACTION", "SUBTRACTION_SCALAR", "TICK", + "TIMESTAMPS", "UNIQUE_TIMESTAMPS", "XOR", ] diff --git a/temporian/test/public_api_test.py b/temporian/test/public_api_test.py index 773eefeaa..06eb181d8 100644 --- a/temporian/test/public_api_test.py +++ b/temporian/test/public_api_test.py @@ -47,6 +47,7 @@ # OPERATORS "cast", "drop_index", + "enumerate", "filter", "glue", "add_index", @@ -58,11 +59,12 @@ "resample", "select", "rename", - "unique_timestamps", "since_last", "begin", "end", "tick", + "timestamps", + "unique_timestamps", # BINARY OPERATORS "add", "subtract", diff --git a/tools/create_operator.py b/tools/create_operator.py index df9589e94..e99c0f85b 100755 --- a/tools/create_operator.py +++ b/tools/create_operator.py @@ -355,6 +355,7 @@ def test_base(self): """Don't forget to register the new operators in: - The imports in the top-level init file temporian/__init__.py - The imports in temporian/implementation/numpy/operators/__init__.py +- The "operators" py_library in temporian/implementation/numpy/operators/BUILD - The "test_base" function in temporian/core/test/registered_operators_test.py - The "test_base" function in temporian/implementation/numpy/test/registered_operators_test.py - The PUBLIC_API_SYMBOLS set in temporian/test/public_symbols_test.py