Skip to content

Commit

Permalink
RedisCounterHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
arcangelo7 committed Sep 27, 2024
1 parent efb7c46 commit f5f6438
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 45 deletions.
189 changes: 189 additions & 0 deletions oc_ocdm/counter_handler/redis_counter_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2024, Arcangelo Massari <arcangelo.massari@unibo.it>
#
# Permission to use, copy, modify, and/or distribute this software for any purpose
# with or without fee is hereby granted, provided that the above copyright notice
# and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT,
# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
# DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS
# SOFTWARE.

from typing import Optional, Union

import redis
from oc_ocdm.counter_handler.counter_handler import CounterHandler


class RedisCounterHandler(CounterHandler):
"""A concrete implementation of the ``CounterHandler`` interface that persistently stores
the counter values within a Redis database."""

def __init__(self, host: str = 'localhost', port: int = 6379, db: int = 0, password: Optional[str] = None) -> None:
"""
Constructor of the ``RedisCounterHandler`` class.
:param host: Redis server host
:type host: str
:param port: Redis server port
:type port: int
:param db: Redis database number
:type db: int
:param password: Redis password (if required)
:type password: Optional[str]
"""
self.redis = redis.Redis(host=host, port=port, db=db, password=password, decode_responses=True)

def set_counter(self, new_value: int, entity_short_name: str, prov_short_name: str = "",
identifier: int = 1, supplier_prefix: str = "") -> None:
"""
It allows to set the counter value of graph and provenance entities.
:param new_value: The new counter value to be set
:type new_value: int
:param entity_short_name: The short name associated either to the type of the entity itself
or, in case of a provenance entity, to the type of the relative graph entity.
:type entity_short_name: str
:param prov_short_name: In case of a provenance entity, the short name associated to the type
of the entity itself. An empty string otherwise.
:type prov_short_name: str
:param identifier: In case of a provenance entity, the counter value that identifies the relative
graph entity. The integer value '1' otherwise.
:type identifier: int
:param supplier_prefix: The supplier prefix
:type supplier_prefix: str
:raises ValueError: if ``new_value`` is a negative integer
:return: None
"""
if new_value < 0:
raise ValueError("new_value must be a non negative integer!")

key = self._get_key(entity_short_name, prov_short_name, identifier, supplier_prefix)
print(key)
self.redis.set(key, new_value)

def read_counter(self, entity_short_name: str, prov_short_name: str = "", identifier: int = 1, supplier_prefix: str = "") -> int:
"""
It allows to read the counter value of graph and provenance entities.
:param entity_short_name: The short name associated either to the type of the entity itself
or, in case of a provenance entity, to the type of the relative graph entity.
:type entity_short_name: str
:param prov_short_name: In case of a provenance entity, the short name associated to the type
of the entity itself. An empty string otherwise.
:type prov_short_name: str
:param identifier: In case of a provenance entity, the counter value that identifies the relative
graph entity. The integer value '1' otherwise.
:type identifier: int
:param supplier_prefix: The supplier prefix
:type supplier_prefix: str
:return: The requested counter value.
"""
key = self._get_key(entity_short_name, prov_short_name, identifier, supplier_prefix)
value = self.redis.get(key)
return int(value) if value is not None else 0

def increment_counter(self, entity_short_name: str, prov_short_name: str = "", identifier: int = 1, supplier_prefix: str = "") -> int:
"""
It allows to increment the counter value of graph and provenance entities by one unit.
:param entity_short_name: The short name associated either to the type of the entity itself
or, in case of a provenance entity, to the type of the relative graph entity.
:type entity_short_name: str
:param prov_short_name: In case of a provenance entity, the short name associated to the type
of the entity itself. An empty string otherwise.
:type prov_short_name: str
:param identifier: In case of a provenance entity, the counter value that identifies the relative
graph entity. The integer value '1' otherwise.
:type identifier: int
:param supplier_prefix: The supplier prefix
:type supplier_prefix: str
:return: The newly-updated (already incremented) counter value.
"""
key = self._get_key(entity_short_name, prov_short_name, identifier, supplier_prefix)
return self.redis.incr(key)

def set_metadata_counter(self, new_value: int, entity_short_name: str, dataset_name: str) -> None:
"""
It allows to set the counter value of metadata entities.
:param new_value: The new counter value to be set
:type new_value: int
:param entity_short_name: The short name associated either to the type of the entity itself.
:type entity_short_name: str
:param dataset_name: In case of a ``Dataset``, its name. Otherwise, the name of the relative dataset.
:type dataset_name: str
:raises ValueError: if ``new_value`` is a negative integer or ``dataset_name`` is None
:return: None
"""
if new_value < 0:
raise ValueError("new_value must be a non negative integer!")

if dataset_name is None:
raise ValueError("dataset_name must be provided!")

key = f"metadata:{dataset_name}:{entity_short_name}"
self.redis.set(key, new_value)

def read_metadata_counter(self, entity_short_name: str, dataset_name: str) -> int:
"""
It allows to read the counter value of metadata entities.
:param entity_short_name: The short name associated either to the type of the entity itself.
:type entity_short_name: str
:param dataset_name: In case of a ``Dataset``, its name. Otherwise, the name of the relative dataset.
:type dataset_name: str
:raises ValueError: if ``dataset_name`` is None
:return: The requested counter value.
"""
if dataset_name is None:
raise ValueError("dataset_name must be provided!")

key = f"metadata:{dataset_name}:{entity_short_name}"
value = self.redis.get(key)
return int(value) if value is not None else 0

def increment_metadata_counter(self, entity_short_name: str, dataset_name: str) -> int:
"""
It allows to increment the counter value of metadata entities by one unit.
:param entity_short_name: The short name associated either to the type of the entity itself.
:type entity_short_name: str
:param dataset_name: In case of a ``Dataset``, its name. Otherwise, the name of the relative dataset.
:type dataset_name: str
:raises ValueError: if ``dataset_name`` is None
:return: The newly-updated (already incremented) counter value.
"""
if dataset_name is None:
raise ValueError("dataset_name must be provided!")

key = f"metadata:{dataset_name}:{entity_short_name}"
return self.redis.incr(key)

def _get_key(self, entity_short_name: str, prov_short_name: str = "", identifier: Union[str, int, None] = None, supplier_prefix: str = "") -> str:
"""
Generate a Redis key for the given parameters.
:param entity_short_name: The short name associated either to the type of the entity itself
or, in case of a provenance entity, to the type of the relative graph entity.
:type entity_short_name: str
:param prov_short_name: In case of a provenance entity, the short name associated to the type
of the entity itself. An empty string otherwise.
:type prov_short_name: str
:param identifier: In case of a provenance entity, the identifier of the relative graph entity.
:type identifier: Union[str, int, None]
:param supplier_prefix: The supplier prefix
:type supplier_prefix: str
:return: The generated Redis key
:rtype: str
"""
key_parts = [entity_short_name, supplier_prefix]
if prov_short_name:
key_parts.append(str(identifier))
key_parts.append(prov_short_name)
return ':'.join(filter(None, key_parts))
129 changes: 129 additions & 0 deletions oc_ocdm/test/counter_handler/test_redis_counter_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2024, Arcangelo Massari <arcangelo.massari@unibo.it>
#
# Permission to use, copy, modify, and/or distribute this software for any purpose
# with or without fee is hereby granted, provided that the above copyright notice
# and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT,
# OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
# DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS
# SOFTWARE.

import unittest
from unittest.mock import MagicMock, patch

from oc_ocdm.counter_handler.redis_counter_handler import RedisCounterHandler


class TestRedisCounterHandler(unittest.TestCase):

def setUp(self):
self.mock_redis = MagicMock()
with patch('redis.Redis', return_value=self.mock_redis):
self.counter_handler = RedisCounterHandler(host='localhost', port=6379, db=0)

def test_set_counter(self):
with self.subTest("Set counter for bibliographic resource"):
self.counter_handler.set_counter(1, "br", supplier_prefix="060")
self.mock_redis.set.assert_called_with("br:060", 1)

with self.subTest("Set counter for identifier"):
self.counter_handler.set_counter(5, "id", supplier_prefix="060")
self.mock_redis.set.assert_called_with("id:060", 5)

with self.subTest("Set counter for agent role"):
self.counter_handler.set_counter(10, "ar", supplier_prefix="060")
self.mock_redis.set.assert_called_with("ar:060", 10)

with self.subTest("Set counter for responsible agent"):
self.counter_handler.set_counter(15, "ra", supplier_prefix="060")
self.mock_redis.set.assert_called_with("ra:060", 15)

with self.subTest("Set counter for resource embodiment"):
self.counter_handler.set_counter(20, "re", supplier_prefix="060")
self.mock_redis.set.assert_called_with("re:060", 20)

with self.subTest("Set provenance counter"):
self.counter_handler.set_counter(2, "br", "se", "1", "060")
self.mock_redis.set.assert_called_with("br:060:1:se", 2)

with self.subTest("Wrong inputs"):
with self.assertRaises(ValueError):
self.counter_handler.set_counter(-1, "br", supplier_prefix="060")

def test_read_counter(self):
with self.subTest("Read counter for bibliographic resource"):
self.mock_redis.get.return_value = "1"
result = self.counter_handler.read_counter("br", supplier_prefix="060")
self.assertEqual(result, 1)
self.mock_redis.get.assert_called_with("br:060")

with self.subTest("Read provenance counter"):
self.mock_redis.get.return_value = "2"
result = self.counter_handler.read_counter("br", "se", "1", "060")
self.assertEqual(result, 2)
self.mock_redis.get.assert_called_with("br:060:1:se")

with self.subTest("Read non-existent counter"):
self.mock_redis.get.return_value = None
result = self.counter_handler.read_counter("br", supplier_prefix="060")
self.assertEqual(result, 0)

def test_increment_counter(self):
with self.subTest("Increment counter for bibliographic resource"):
self.mock_redis.incr.return_value = 2
result = self.counter_handler.increment_counter("br", supplier_prefix="060")
self.assertEqual(result, 2)
self.mock_redis.incr.assert_called_with("br:060")

with self.subTest("Increment provenance counter"):
self.mock_redis.incr.return_value = 3
result = self.counter_handler.increment_counter("br", "se", "1", "060")
self.assertEqual(result, 3)
self.mock_redis.incr.assert_called_with("br:060:1:se")

def test_set_metadata_counter(self):
with self.subTest("Set metadata counter"):
self.counter_handler.set_metadata_counter(5, "di", "http://dataset/")
self.mock_redis.set.assert_called_with("metadata:http://dataset/:di", 5)

with self.subTest("Wrong inputs"):
with self.assertRaises(ValueError):
self.counter_handler.set_metadata_counter(-1, "di", "http://dataset/")
with self.assertRaises(ValueError):
self.counter_handler.set_metadata_counter(1, "di", None)

def test_read_metadata_counter(self):
with self.subTest("Read metadata counter"):
self.mock_redis.get.return_value = "5"
result = self.counter_handler.read_metadata_counter("di", "http://dataset/")
self.assertEqual(result, 5)
self.mock_redis.get.assert_called_with("metadata:http://dataset/:di")

with self.subTest("Read non-existent metadata counter"):
self.mock_redis.get.return_value = None
result = self.counter_handler.read_metadata_counter("di", "http://dataset/")
self.assertEqual(result, 0)

with self.subTest("Wrong inputs"):
with self.assertRaises(ValueError):
self.counter_handler.read_metadata_counter("di", None)

def test_increment_metadata_counter(self):
with self.subTest("Increment metadata counter"):
self.mock_redis.incr.return_value = 6
result = self.counter_handler.increment_metadata_counter("di", "http://dataset/")
self.assertEqual(result, 6)
self.mock_redis.incr.assert_called_with("metadata:http://dataset/:di")

with self.subTest("Wrong inputs"):
with self.assertRaises(ValueError):
self.counter_handler.increment_metadata_counter("di", None)

if __name__ == '__main__':
unittest.main()
Loading

0 comments on commit f5f6438

Please sign in to comment.