diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5f8dc6b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,19 @@ +[run] +branch = True +source = sieve_cache +omit = + __init__.py +concurrency = multiprocessing,thread +[report] +ignore_errors = True +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + class .*\bProtocol\): + @(abc\.)?abstractmethod \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..6ed6dfa --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,35 @@ +name: Python CI + +on: + push: + branches: + - main + pull_request: + +jobs: + tox: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install tox + run: uv tool install tox --with tox-uv + + - name: Run tox (tests + lint) + run: tox -e py3,pep8 diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml new file mode 100644 index 0000000..e1c00e8 --- /dev/null +++ b/.github/workflows/release-pypi.yml @@ -0,0 +1,47 @@ +name: Release to PyPI + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build distribution artifacts + run: | + python -m pip install build + python -m build + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + upload-to-pypi: + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..80914f8 --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=${OS_TEST_PATH:-./sieve_cache/tests} +top_dir=./ diff --git a/pyproject.toml b/pyproject.toml index adf5a94..973f6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ redis = ["redis"] [project.entry-points."dogpile.cache"] -sieve_cache.memory = "sieve_cache.backends.memory:InMemoryDriver" +"sieve_cache.memory" = "sieve_cache.backends.memory:InMemoryDriver" [tool.setuptools.dynamic] version = { attr = "sieve_cache.version.version_string" } diff --git a/requirements.txt b/requirements.txt index d224410..a6b25b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +pbr>=6.0.0 stevedore>=5.1.0 dogpile.cache>=1.3.0 iso8601>=2.1.0 \ No newline at end of file diff --git a/sieve_cache/tests/test_memory_backend.py b/sieve_cache/tests/test_memory_backend.py new file mode 100644 index 0000000..1ad2e23 --- /dev/null +++ b/sieve_cache/tests/test_memory_backend.py @@ -0,0 +1,78 @@ +# 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. + +from unittest import TestCase +from unittest import mock + +from dogpile.cache import api + +from sieve_cache.backends.memory import InMemoryDriver + + +class TestInMemoryDriver(TestCase): + def _make_backend(self, expiration_time): + backend = InMemoryDriver.__new__(InMemoryDriver) + backend.expiration_time = expiration_time + backend.cache = {} + return backend + + def test_set_and_get_without_expiration(self): + backend = self._make_backend(expiration_time=0) + + backend.set("a", 1) + + self.assertEqual(1, backend.get("a")) + self.assertEqual(api.NO_VALUE, backend.get("missing")) + + def test_get_multi_and_delete_methods(self): + backend = self._make_backend(expiration_time=0) + backend.set_multi({"a": 1, "b": 2}) + + values = list(backend.get_multi(["a", "b", "c"])) + + self.assertEqual([1, 2, api.NO_VALUE], values) + + backend.delete("a") + backend.delete_multi(["b", "missing"]) + self.assertEqual(api.NO_VALUE, backend.get("a")) + self.assertEqual(api.NO_VALUE, backend.get("b")) + + def test_get_returns_no_value_for_expired_keys(self): + backend = self._make_backend(expiration_time=10) + + with mock.patch( + "sieve_cache.backends.memory.timeutils.utcnow_ts" + ) as now: + now.return_value = 100 + backend.set("a", 1) + + now.return_value = 105 + self.assertEqual(1, backend.get("a")) + + now.return_value = 110 + self.assertEqual(api.NO_VALUE, backend.get("a")) + self.assertNotIn("a", backend.cache) + + def test_set_multi_clears_expired_keys(self): + backend = self._make_backend(expiration_time=10) + + with mock.patch( + "sieve_cache.backends.memory.timeutils.utcnow_ts" + ) as now: + now.return_value = 100 + backend.set("old", "value") + + now.return_value = 500 + backend.set_multi({"new": "value"}) + + self.assertIn("new", backend.cache) + self.assertNotIn("old", backend.cache) diff --git a/sieve_cache/tests/test_node.py b/sieve_cache/tests/test_node.py new file mode 100644 index 0000000..63fb859 --- /dev/null +++ b/sieve_cache/tests/test_node.py @@ -0,0 +1,48 @@ +# 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. + +from unittest import TestCase + +from sieve_cache.node import Node + + +class TestNode(TestCase): + def test_to_dict_handles_nested_nodes_and_sequences(self): + child = Node(value=123, key="child") + parent = Node(value=[child, "raw"], key="parent", visited=True) + + payload = parent.to_dict() + + self.assertEqual("parent", payload["key"]) + self.assertTrue(payload["visited"]) + self.assertEqual(123, payload["value"][0]["value"]) + self.assertEqual("raw", payload["value"][1]) + + def test_dict_method_returns_serialized_payload(self): + node = Node(value=1, key="k", next="n", prev="p") + + payload = node.__dict__() + + self.assertEqual("k", payload["key"]) + self.assertEqual("n", payload["next"]) + self.assertEqual("p", payload["prev"]) + + def test_getitem_and_setitem(self): + node = Node(value=1, key="k") + + self.assertEqual("k", node["key"]) + + node["visited"] = True + self.assertTrue(node.visited) + + with self.assertRaises(KeyError): + node["missing"] diff --git a/sieve_cache/tests/test_timeutils.py b/sieve_cache/tests/test_timeutils.py new file mode 100644 index 0000000..bf8b866 --- /dev/null +++ b/sieve_cache/tests/test_timeutils.py @@ -0,0 +1,57 @@ +# 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 +from unittest import TestCase +from unittest import mock + +import iso8601 + +from sieve_cache.common import timeutils + + +class TestTimeutils(TestCase): + def tearDown(self): + timeutils.utcnow.override_time = None + + def test_utcnow_ts_uses_time_module_when_no_override(self): + with mock.patch("time.time", return_value=1700000000.75): + self.assertEqual(1700000000, timeutils.utcnow_ts()) + self.assertEqual( + 1700000000.75, timeutils.utcnow_ts(microsecond=True) + ) + + def test_utcnow_ts_uses_override_time_and_microseconds(self): + fixed = datetime.datetime(2024, 1, 1, 12, 0, 1, 500000) + timeutils.utcnow.override_time = fixed + + self.assertEqual(1704110401, timeutils.utcnow_ts()) + self.assertEqual(1704110401.5, timeutils.utcnow_ts(microsecond=True)) + + def test_utcnow_returns_timezone_aware_when_requested(self): + value = timeutils.utcnow(with_timezone=True) + + self.assertIsNotNone(value.tzinfo) + self.assertEqual(iso8601.iso8601.UTC, value.tzinfo) + + def test_utcnow_returns_naive_datetime_by_default(self): + value = timeutils.utcnow() + + self.assertIsNone(value.tzinfo) + + def test_utcnow_uses_override_list(self): + first = datetime.datetime(2024, 1, 1, 0, 0, 0) + second = datetime.datetime(2024, 1, 1, 0, 0, 1) + timeutils.utcnow.override_time = [first, second] + + self.assertEqual(first, timeutils.utcnow()) + self.assertEqual(second, timeutils.utcnow()) diff --git a/sieve_cache/tests/test_version.py b/sieve_cache/tests/test_version.py new file mode 100644 index 0000000..a70e27e --- /dev/null +++ b/sieve_cache/tests/test_version.py @@ -0,0 +1,21 @@ +# 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. + +from unittest import TestCase + +from sieve_cache import version + + +class TestVersionModule(TestCase): + def test_version_info_and_version_string_are_initialized(self): + self.assertIsNotNone(version.version_info) + self.assertIsNotNone(version.version_string) diff --git a/test-requirements.txt b/test-requirements.txt index 9fd10a8..dc34936 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -13,3 +13,4 @@ coverage stestr bandit +flake8 diff --git a/tox.ini b/tox.ini index 5b474c6..df22079 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] requires = tox>=4 -env_list = py3,pep8 +env_list = py3,pep8,cover ignore_basepython_conflict = True [testenv] @@ -15,7 +15,6 @@ setenv = OS_STDERR_CAPTURE={env:OS_STDERR_CAPTURE:true} OS_TEST_TIMEOUT={env:OS_TEST_TIMEOUT:180} PYTHONDONTWRITEBYTECODE=1 - PYTHONWARNINGS=default::DeprecationWarning,ignore::DeprecationWarning:distutils,ignore::DeprecationWarning:site passenv = http_proxy HTTP_PROXY @@ -25,23 +24,19 @@ passenv = NO_PROXY OS_DEBUG usedevelop = True -install_command = - pip install {opts} {packages} commands = - find . -type f -name "*.py[c|o]" -delete stestr run {posargs} stestr slowest deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt allowlist_externals = - bash - find rm + stestr [testenv:pep8] commands = - flake8 seive_cache + flake8 sieve_cache [testenv:venv] commands = {posargs} @@ -49,16 +44,11 @@ commands = {posargs} [testenv:cover] setenv = {[testenv]setenv} - PYTHON=coverage run --source seive_cache --parallel-mode + PYTHON=coverage run --source sieve_cache --parallel-mode commands = coverage erase - find . -type f -name "*.pyc" -delete stestr run --no-subunit-trace {posargs} coverage combine coverage html -d cover coverage xml -o cover/coverage.xml coverage report --fail-under=70 --skip-covered - -[testenv:bandit] -deps = -r{toxinidir}/test-requirements.txt -commands = bandit -r seive_cache -x tests -s B101,B104,B110,B310,B311,B506