diff --git a/CHANGELOG.md b/CHANGELOG.md index c073cd8..3b0dce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/nationalarchives/python-utilities/compare/v1.7.0...HEAD) +## [Unreleased](https://github.com/nationalarchives/python-utilities/compare/v1.8.0...HEAD) ### Added ### Changed @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Security +## [1.8.0](https://github.com/nationalarchives/python-utilities/compare/v1.7.0...v1.8.0) - 2026-08-19 + +### Added + +- Added `tna_frontend_pagination()` to `tna_utilities.component` to generate the entire content block required for [TNA Frontend pagination components](https://design-system.nationalarchives.gov.uk/components/pagination/) + +### Changed + +- `QueryStringTransformer` is now read-only - to modify query strings, create a new editable object with `new()` + ## [1.7.0](https://github.com/nationalarchives/python-utilities/compare/v1.6.0...v1.7.0) - 2026-08-19 ### Added diff --git a/docs/component.md b/docs/component.md index eb00206..d7e2ab7 100644 --- a/docs/component.md +++ b/docs/component.md @@ -26,9 +26,56 @@ print(paginate(42, 7, around=2)) # [1, "...", 5, 6, 7, 8, 9, "...", 42] ``` +## `tna_frontend_pagination()` + +> Added in `v1.8.0`. + +Creates an object that be used directly in a [National Archives pagination component](https://design-system.nationalarchives.gov.uk/components/pagination/) using [`tna_frontend_pagination_items()`](#tna_frontend_pagination_items). + +### Arguments + +| Argument | Description | Default | +| -------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `pages` | The total number of pages to paginate | [none] | +| `current_page` | The number of the current page | [none] | +| `base_url` | The base URL including the blank query string for the page | [none] | +| `custom_properties` | A dictionary of custom properties for the pagination component | [none] | +| `around` | The number of items to always show around the current page | `1` | +| `transformer` | A function to create the item given a number and whether it is the current page | `tna_utilities.component.tna_frontend_pagination_item_transformer` | +| `ellipsis` | A dictionary to use in place of an ellipsis | `{"ellipsis": True}` | +| `previous_page_properties` | Properties to use for the previous page button | [none] | +| `next_page_properties` | Properties to use for the next page button | [none] | + +### Example + +```python +from tna_utilities.component import tna_frontend_pagination + +print(tna_frontend_pagination(42, 7, "?page=", {"landmarkLabel": "Pages of results"}, next_page_properties={"text": "Go on..."})) +# { +# "landmarkLabel": "Pages of results", +# "items": [ +# {"number": 1, "current": False, "href": "?page=1"}, +# {"ellipsis": True}, +# {"number": 6, "current": False, "href": "?page=6"}, +# {"number": 7, "current": True, "href": "?page=7"}, +# {"number": 8, "current": False, "href": "?page=8"}, +# {"ellipsis": True}, +# {"number": 42, "current": False, "href": "?page=42"}, +# ], +# "previous": { +# "href": "?page=6", +# }, +# "next": { +# "text": "Go on...", +# "href": "?page=8", +# }, +# } +``` + ## `tna_frontend_pagination_items()` -Creates an object that be used directly in a [National Archives pagination component](https://design-system.nationalarchives.gov.uk/components/pagination/). +Creates a list of items that be used directly in a [National Archives pagination component](https://design-system.nationalarchives.gov.uk/components/pagination/). ### Arguments @@ -44,7 +91,7 @@ Creates an object that be used directly in a [National Archives pagination compo ### Example ```python -from tna_utilities.component import paginate +from tna_utilities.component import tna_frontend_pagination_items print(tna_frontend_pagination_items(42, 7, "?page=")) # [ diff --git a/docs/index.md b/docs/index.md index 1fae51c..b60d127 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,3 +16,31 @@ This is a library of common Python functions, some specific to The National Arch - [Flask](./flask.md) [Read the changelog](https://github.com/nationalarchives/python-utilities/blob/main/CHANGELOG.md). + +## Root-level functions + +### `strtobool()` + +Converts a string to boolean based on a number of predefined truthy and falsy values. + +#### Arguments + +| Argument | Description | Default | +| -------- | -------------------- | ------- | +| `value` | The value to convert | [none] | + +#### Example + +```python +from tna_utilities import strtobool + +print(strtobool("yes")) +# True + +print(strtobool("0")) +# False + +print(strtobool("maybe")) # Raises ValueError: Invalid truth value + +print(strtobool(True)) # Raises TypeError: Invalid truth value +``` diff --git a/docs/url.md b/docs/url.md index 9634347..38a06fe 100644 --- a/docs/url.md +++ b/docs/url.md @@ -76,7 +76,9 @@ qs.is_value_in_parameter("b", "4") # False ``` -### Add and remove parameters +### Add, update and remove parameters + +Here, we are creating a new query string using `.new()` which creates a modifyable object which allows us to add, update and remove parameters and their values. ```python from tna_utilities.url import QueryStringTransformer @@ -84,48 +86,33 @@ from tna_utilities.url import QueryStringTransformer # ?a=1&b=2&b=3 qs = QueryStringTransformer([("a", ["1"]), ("b", ["2", "3"])]) -qs.add_parameter("c", "4") -qs.update_parameter("b", ["5", "6"]) -qs.remove_parameter("a") +new_qs = qs.new() -print(qs.get_query_string()) -# ?b=5&b=6&c=4 +new_qs.add_parameter("c", "4") +new_qs.update_parameter("b", ["5", "6"]) +new_qs.add_parameter_value("b", "7") +new_qs.toggle_parameter_value("b", "1") +new_qs.remove_parameter_value("b", "5") +new_qs.remove_parameter("a") +print(new_qs.get_query_string()) +# ?b=1&b=6&b=7&c=4 # Chainable (as of v1.1.0) -print(qs.add_parameter( +print(new_qs.add_parameter( "c", "4" ).update_parameter( "b", ["5", "6"] +).add_parameter_value( + "b", "7" +).toggle_parameter_value( + "b", "1" +).remove_parameter_value( + "b", "5" ).remove_parameter( "a" ).get_query_string()) ``` -### Update parameter values - -```python -from tna_utilities.url import QueryStringTransformer - -# ?a=1&b=2&b=3 -qs = QueryStringTransformer([("a", ["1"]), ("b", ["2", "3"])]) - -qs.add_parameter_value("a", "4") -qs.toggle_parameter_value("b", "3") -qs.remove_parameter_value("a", "1") - -print(qs.get_query_string()) -# ?a=4&b=2 - -# Chainable (as of v1.1.0) -new_query_string = qs.add_parameter_value( - "a", "4" -).toggle_parameter_value( - "b", "3" -).remove_parameter_value( - "a", "1" -).get_query_string() -``` - ### Tolerant mode > Added in `v1.7.0`. @@ -135,12 +122,14 @@ from tna_utilities.url import QueryStringTransformer # ?a=1 qs = QueryStringTransformer([("a", ["1"])]) -qs.remove_parameter_value("b", "2") # Raises KeyError: Parameter 'b' does not exist -qs.is_value_in_parameter("c", "3") # Raises KeyError: Parameter 'c' does not exist +new_qs = qs.new() +new_qs.remove_parameter_value("b", "2") # Raises KeyError: Parameter 'b' does not exist +print(new_qs.is_value_in_parameter("c", "3")) # Raises KeyError: Parameter 'c' does not exist # ?a=1 -qs_tolerant = QueryStringTransformer([("a", ["1"])], tolerant=True) -qs_tolerant.remove_parameter_value("b", "2") # No exception raised -print(qs_tolerant.is_value_in_parameter("c", "3")) +tolerant_qs = QueryStringTransformer([("a", ["1"])], tolerant=True) +new_tolerant_qs = tolerant_qs.new() +new_tolerant_qs.remove_parameter_value("b", "2") # No exception raised +print(new_tolerant_qs.is_value_in_parameter("c", "3")) # False ``` diff --git a/pyproject.toml b/pyproject.toml index fe9dc24..63329e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tna-utilities" -version = "1.7.0" +version = "1.8.0" requires-python = ">=3.10" authors = [ {name = "Andrew Hosgood", email = "andrew.hosgood@nationalarchives.gov.uk"}, diff --git a/tests/test_component.py b/tests/test_component.py index 2cc3095..2d42d84 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -3,6 +3,7 @@ from tna_utilities.component import ( PAGINATION_GAP, paginate, + tna_frontend_pagination, tna_frontend_pagination_items, ) @@ -324,7 +325,7 @@ def test_pagination_negative_around(self): with self.assertRaises(ValueError): paginate(42, 1, around=-1) - def test_tna_pagination_items(self): + def test_tna_frontend_pagination_items(self): self.assertEqual( tna_frontend_pagination_items(42, 6, "/test?page="), [ @@ -338,7 +339,7 @@ def test_tna_pagination_items(self): ], ) - def test_tna_pagination_items_custom_transformer(self): + def test_tna_frontend_pagination_items_custom_transformer(self): def custom_transformer(item, current_page, base_url): return { "page": item, @@ -361,7 +362,7 @@ def custom_transformer(item, current_page, base_url): ], ) - def test_tna_pagination_items_custom_transformer_lambda(self): + def test_tna_frontend_pagination_items_custom_transformer_lambda(self): self.assertEqual( tna_frontend_pagination_items( 42, @@ -384,7 +385,7 @@ def test_tna_pagination_items_custom_transformer_lambda(self): ], ) - def test_tna_pagination_items_custom_ellipsis(self): + def test_tna_frontend_pagination_items_custom_ellipsis(self): self.assertEqual( tna_frontend_pagination_items( 42, 6, "/test?page=", ellipsis={"number": None} @@ -399,3 +400,132 @@ def test_tna_pagination_items_custom_ellipsis(self): {"number": 42, "current": False, "href": "/test?page=42"}, ], ) + + def test_tna_frontend_pagination(self): + pagination = tna_frontend_pagination(42, 6, "/test?page=") + + self.assertIn("items", pagination) + self.assertEqual( + pagination["items"], + [ + {"number": 1, "current": False, "href": "/test?page=1"}, + {"ellipsis": True}, + {"number": 5, "current": False, "href": "/test?page=5"}, + {"number": 6, "current": True, "href": "/test?page=6"}, + {"number": 7, "current": False, "href": "/test?page=7"}, + {"ellipsis": True}, + {"number": 42, "current": False, "href": "/test?page=42"}, + ], + ) + + self.assertIn("previous", pagination) + self.assertEqual( + pagination["previous"], + { + "href": "/test?page=5", + }, + ) + + self.assertIn("next", pagination) + self.assertEqual( + pagination["next"], + { + "href": "/test?page=7", + }, + ) + + def test_tna_frontend_pagination_first(self): + pagination = tna_frontend_pagination(42, 1, "/test?page=") + + self.assertIn("items", pagination) + self.assertEqual( + pagination["items"], + [ + {"number": 1, "current": True, "href": "/test?page=1"}, + {"number": 2, "current": False, "href": "/test?page=2"}, + {"ellipsis": True}, + {"number": 42, "current": False, "href": "/test?page=42"}, + ], + ) + + self.assertNotIn("previous", pagination) + + self.assertIn("next", pagination) + self.assertEqual( + pagination["next"], + { + "href": "/test?page=2", + }, + ) + + def test_tna_frontend_pagination_last(self): + pagination = tna_frontend_pagination(42, 42, "/test?page=") + + self.assertIn("items", pagination) + self.assertEqual( + pagination["items"], + [ + {"number": 1, "current": False, "href": "/test?page=1"}, + {"ellipsis": True}, + {"number": 41, "current": False, "href": "/test?page=41"}, + {"number": 42, "current": True, "href": "/test?page=42"}, + ], + ) + + self.assertIn("previous", pagination) + self.assertEqual( + pagination["previous"], + { + "href": "/test?page=41", + }, + ) + + self.assertNotIn("next", pagination) + + def test_tna_frontend_pagination_custom_properties(self): + pagination = tna_frontend_pagination( + 42, + 6, + "/test?page=", + custom_properties={ + "items": "foobar", + "classes": "test-class", + "attributes": {"data-test": "value"}, + }, + ) + + self.assertIn("items", pagination) + self.assertNotEqual(pagination["items"], "foobar") + + self.assertIn("classes", pagination) + self.assertEqual(pagination["classes"], "test-class") + + self.assertIn("attributes", pagination) + self.assertEqual(pagination["attributes"], {"data-test": "value"}) + + def test_tna_frontend_pagination_custom_next_previous_properties(self): + pagination = tna_frontend_pagination( + 42, + 6, + "/test?page=", + previous_page_properties={"text": "Go back one", "href": "NONONO"}, + next_page_properties={ + "title": "Another page please", + "description": "foobar", + }, + ) + + self.assertIn("previous", pagination) + self.assertEqual( + pagination["previous"], {"text": "Go back one", "href": "/test?page=5"} + ) + + self.assertIn("next", pagination) + self.assertEqual( + pagination["next"], + { + "href": "/test?page=7", + "title": "Another page please", + "description": "foobar", + }, + ) diff --git a/tests/test_root.py b/tests/test_root.py new file mode 100644 index 0000000..7b0d155 --- /dev/null +++ b/tests/test_root.py @@ -0,0 +1,65 @@ +import unittest + +from tna_utilities import strtobool + + +class TestStrToBool(unittest.TestCase): + def test_truthy_values(self): + self.assertTrue(strtobool("yes")) + self.assertTrue(strtobool("y")) + self.assertTrue(strtobool("yES")) + self.assertTrue(strtobool("t")) + self.assertTrue(strtobool("true")) + self.assertTrue(strtobool("True")) + self.assertTrue(strtobool("TRUE")) + self.assertTrue(strtobool("on")) + self.assertTrue(strtobool("ON")) + self.assertTrue(strtobool("1")) + + def test_falsy_values(self): + self.assertFalse(strtobool("no")) + self.assertFalse(strtobool("n")) + self.assertFalse(strtobool("NO")) + self.assertFalse(strtobool("f")) + self.assertFalse(strtobool("false")) + self.assertFalse(strtobool("False")) + self.assertFalse(strtobool("FALSE")) + self.assertFalse(strtobool("off")) + self.assertFalse(strtobool("OFF")) + self.assertFalse(strtobool("0")) + + def test_incorrect_values(self): + with self.assertRaises(ValueError): + strtobool(" yes ") + with self.assertRaises(ValueError): + strtobool("2") + with self.assertRaises(ValueError): + self.assertFalse(strtobool("")) + with self.assertRaises(ValueError): + self.assertFalse(strtobool("yeah")) + with self.assertRaises(ValueError): + self.assertFalse(strtobool(" ")) + + def test_invalid_values(self): + with self.assertRaises(TypeError): + strtobool(True) + with self.assertRaises(TypeError): + strtobool(False) + with self.assertRaises(TypeError): + strtobool(None) + with self.assertRaises(TypeError): + strtobool(0) + with self.assertRaises(TypeError): + strtobool(1) + with self.assertRaises(TypeError): + strtobool(3.14) + with self.assertRaises(TypeError): + strtobool([]) + with self.assertRaises(TypeError): + strtobool({}) + with self.assertRaises(TypeError): + strtobool(()) + with self.assertRaises(TypeError): + strtobool(lambda: "true") + with self.assertRaises(TypeError): + strtobool() diff --git a/tests/test_strtobool.py b/tests/test_strtobool.py deleted file mode 100644 index fc959b1..0000000 --- a/tests/test_strtobool.py +++ /dev/null @@ -1,40 +0,0 @@ -import unittest - -from tna_utilities import strtobool - - -class TestStrToBool(unittest.TestCase): - def test_happy_truthy(self): - self.assertEqual(strtobool("y"), True) - self.assertEqual(strtobool("Y"), True) - self.assertEqual(strtobool("t"), True) - self.assertEqual(strtobool("true"), True) - self.assertEqual(strtobool("on"), True) - self.assertEqual(strtobool("1"), True) - - def test_happy_falsy(self): - self.assertEqual(strtobool("n"), False) - self.assertEqual(strtobool("N"), False) - self.assertEqual(strtobool("no"), False) - self.assertEqual(strtobool("f"), False) - self.assertEqual(strtobool("false"), False) - self.assertEqual(strtobool("off"), False) - self.assertEqual(strtobool("0"), False) - - def test_unhappy_invalid(self): - with self.assertRaises(ValueError): - strtobool("") - with self.assertRaises(ValueError): - strtobool("maybe") - with self.assertRaises(TypeError): - strtobool(True) - with self.assertRaises(TypeError): - strtobool(False) - with self.assertRaises(TypeError): - strtobool(42) - with self.assertRaises(TypeError): - strtobool(None) - with self.assertRaises(TypeError): - strtobool({}) - with self.assertRaises(TypeError): - strtobool(()) diff --git a/tests/test_url.py b/tests/test_url.py index cf64303..1d25659 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -10,12 +10,15 @@ def __init__(self, *args, **kwargs): def test_init(self): manipulator = QueryStringTransformer() - self.assertEqual(manipulator.get_query_string(), "") - self.assertEqual(manipulator.add_parameter("foo", "bar"), manipulator) - self.assertEqual(manipulator.get_query_string(), "?foo=bar") + new_qs = manipulator.new() + + self.assertEqual(new_qs.get_query_string(), "") + self.assertEqual(new_qs.add_parameter("foo", "bar"), new_qs) + self.assertEqual(new_qs.get_query_string(), "?foo=bar") def test_list_init(self): manipulator = QueryStringTransformer(self.test_query) + self.assertEqual(manipulator.get_query_string(), "?a=1&b=2&b=3") def test_object_init(self): @@ -30,9 +33,11 @@ def lists(self): test_query = TestQueryStringObject() manipulator = QueryStringTransformer(test_query) - self.assertEqual(manipulator.get_query_string(), "?a=1&b=2&b=3") - self.assertEqual(manipulator.add_parameter("foo", "bar"), manipulator) - self.assertEqual(manipulator.get_query_string(), "?a=1&b=2&b=3&foo=bar") + new_qs = manipulator.new() + + self.assertEqual(new_qs.get_query_string(), "?a=1&b=2&b=3") + self.assertEqual(new_qs.add_parameter("foo", "bar"), new_qs) + self.assertEqual(new_qs.get_query_string(), "?a=1&b=2&b=3&foo=bar") def test_unhappy_init(self): with self.assertRaises(AttributeError): @@ -40,6 +45,7 @@ def test_unhappy_init(self): def test_parameter_values(self): manipulator = QueryStringTransformer(self.test_query) + self.assertEqual(manipulator.parameter_values("a"), ["1"]) self.assertEqual(manipulator.parameter_values("b"), ["2", "3"]) with self.assertRaises(KeyError): @@ -47,57 +53,64 @@ def test_parameter_values(self): def test_add_parameter(self): manipulator = QueryStringTransformer(self.test_query) + new_qs = manipulator.new() - self.assertEqual(manipulator.add_parameter("c", []), manipulator) - self.assertTrue(manipulator.parameter_exists("c")) - self.assertEqual(manipulator.parameter_values("c"), []) + self.assertEqual(new_qs.add_parameter("c", []), new_qs) + self.assertTrue(new_qs.parameter_exists("c")) + self.assertEqual(new_qs.parameter_values("c"), []) - self.assertEqual(manipulator.add_parameter("d", None), manipulator) - self.assertTrue(manipulator.parameter_exists("d")) - self.assertEqual(manipulator.parameter_values("d"), []) + self.assertEqual(new_qs.add_parameter("d", None), new_qs) + self.assertTrue(new_qs.parameter_exists("d")) + self.assertEqual(new_qs.parameter_values("d"), []) - self.assertEqual(manipulator.add_parameter("e", ""), manipulator) - self.assertTrue(manipulator.parameter_exists("e")) - self.assertEqual(manipulator.parameter_values("e"), [""]) + self.assertEqual(new_qs.add_parameter("e", ""), new_qs) + self.assertTrue(new_qs.parameter_exists("e")) + self.assertEqual(new_qs.parameter_values("e"), [""]) - self.assertEqual(manipulator.add_parameter("f", "4"), manipulator) - self.assertTrue(manipulator.parameter_exists("f")) - self.assertEqual(manipulator.parameter_values("f"), ["4"]) + self.assertEqual(new_qs.add_parameter("f", "4"), new_qs) + self.assertTrue(new_qs.parameter_exists("f")) + self.assertEqual(new_qs.parameter_values("f"), ["4"]) - self.assertEqual(manipulator.add_parameter("g", ["5", "6"]), manipulator) - self.assertTrue(manipulator.parameter_exists("g")) - self.assertEqual(manipulator.parameter_values("g"), ["5", "6"]) + self.assertEqual(new_qs.add_parameter("g", ["5", "6"]), new_qs) + self.assertTrue(new_qs.parameter_exists("g")) + self.assertEqual(new_qs.parameter_values("g"), ["5", "6"]) - self.assertEqual(manipulator.add_parameter("h", [False]), manipulator) - self.assertTrue(manipulator.parameter_exists("h")) - self.assertEqual(manipulator.parameter_values("h"), ["False"]) + self.assertEqual(new_qs.add_parameter("h", [False]), new_qs) + self.assertTrue(new_qs.parameter_exists("h")) + self.assertEqual(new_qs.parameter_values("h"), ["False"]) with self.assertRaises(ValueError): - manipulator.add_parameter("h", [True]) + new_qs.add_parameter("h", [True]) self.assertEqual( - manipulator.get_query_string(), "?a=1&b=2&b=3&e=&f=4&g=5&g=6&h=False" + new_qs.get_query_string(), "?a=1&b=2&b=3&e=&f=4&g=5&g=6&h=False" ) def test_update_parameter(self): manipulator = QueryStringTransformer(self.test_query) - self.assertEqual(manipulator.update_parameter("a", "10"), manipulator) - self.assertEqual(manipulator.parameter_values("a"), ["10"]) - self.assertEqual(manipulator.update_parameter("b", ["20", "30"]), manipulator) - self.assertEqual(manipulator.parameter_values("b"), ["20", "30"]) - self.assertEqual(manipulator.update_parameter("c", ["40"]), manipulator) - self.assertEqual(manipulator.parameter_values("c"), ["40"]) - self.assertEqual(manipulator.get_query_string(), "?a=10&b=20&b=30&c=40") + new_qs = manipulator.new() + + self.assertEqual(new_qs.update_parameter("a", "10"), new_qs) + self.assertEqual(new_qs.parameter_values("a"), ["10"]) + self.assertEqual(new_qs.update_parameter("b", ["20", "30"]), new_qs) + self.assertEqual(new_qs.parameter_values("b"), ["20", "30"]) + self.assertEqual(new_qs.update_parameter("b", "100"), new_qs) + self.assertEqual(new_qs.parameter_values("b"), ["100"]) + self.assertEqual(new_qs.update_parameter("c", ["40"]), new_qs) + self.assertEqual(new_qs.parameter_values("c"), ["40"]) + self.assertEqual(new_qs.get_query_string(), "?a=10&b=100&c=40") def test_remove_parameter(self): manipulator = QueryStringTransformer(self.test_query) - self.assertEqual(manipulator.remove_parameter("a"), manipulator) - self.assertFalse(manipulator.parameter_exists("a")) - self.assertEqual(manipulator.remove_parameter("b"), manipulator) - self.assertFalse(manipulator.parameter_exists("b")) + new_qs = manipulator.new() + + self.assertEqual(new_qs.remove_parameter("a"), new_qs) + self.assertFalse(new_qs.parameter_exists("a")) + self.assertEqual(new_qs.remove_parameter("b"), new_qs) + self.assertFalse(new_qs.parameter_exists("b")) with self.assertRaises(KeyError): - manipulator.remove_parameter("c") - self.assertEqual(manipulator.get_query_string(), "") + new_qs.remove_parameter("c") + self.assertEqual(new_qs.get_query_string(), "") def test_is_value_in_parameter(self): manipulator = QueryStringTransformer(self.test_query) @@ -110,34 +123,68 @@ def test_is_value_in_parameter(self): def test_toggle_parameter_value(self): manipulator = QueryStringTransformer(self.test_query) - self.assertEqual(manipulator.toggle_parameter_value("a", "1"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("a", "1")) - self.assertEqual(manipulator.toggle_parameter_value("a", "10"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "10")) - self.assertEqual(manipulator.toggle_parameter_value("b", "2"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("b", "2")) - self.assertEqual(manipulator.get_query_string(), "?a=10&b=3") - self.assertEqual(manipulator.toggle_parameter_value("a", "1"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "1")) - self.assertEqual(manipulator.get_query_string(), "?a=10&a=1&b=3") + new_qs = manipulator.new() + + self.assertEqual(new_qs.toggle_parameter_value("a", "1"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("a", "1")) + self.assertEqual(new_qs.toggle_parameter_value("a", "10"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "10")) + self.assertEqual(new_qs.toggle_parameter_value("b", "2"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("b", "2")) + self.assertEqual(new_qs.get_query_string(), "?a=10&b=3") + self.assertEqual(new_qs.toggle_parameter_value("a", "1"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "1")) + self.assertEqual(new_qs.get_query_string(), "?a=10&a=1&b=3") + with self.assertRaises(KeyError): + new_qs.toggle_parameter_value("c", "4") with self.assertRaises(KeyError): - manipulator.toggle_parameter_value("c", "4") + new_qs.toggle_parameter_value("c", "4") def test_add_remove_parameter_value(self): manipulator = QueryStringTransformer(self.test_query) - self.assertEqual(manipulator.add_parameter_value("a", "10"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "10")) - self.assertEqual(manipulator.parameter_values("a"), ["1", "10"]) + new_qs = manipulator.new() + + self.assertEqual(new_qs.add_parameter_value("a", "10"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "10")) + self.assertEqual(new_qs.parameter_values("a"), ["1", "10"]) with self.assertRaises(KeyError): - manipulator.add_parameter_value("c", "4") + new_qs.add_parameter_value("c", "4") def test_remove_parameter_value(self): manipulator = QueryStringTransformer(self.test_query) - self.assertEqual(manipulator.remove_parameter_value("b", "2"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("b", "2")) - self.assertEqual(manipulator.parameter_values("b"), ["3"]) + new_qs = manipulator.new() + + self.assertEqual(new_qs.remove_parameter_value("b", "2"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("b", "2")) + self.assertEqual(new_qs.parameter_values("b"), ["3"]) with self.assertRaises(KeyError): - manipulator.remove_parameter_value("c", "4") + new_qs.remove_parameter_value("c", "4") + + def test_initial_query_string_cannot_be_modified(self): + manipulator = QueryStringTransformer(self.test_query) + + with self.assertRaises(AttributeError): + manipulator.add_parameter("c", "4") + with self.assertRaises(AttributeError): + manipulator.update_parameter("a", ["3"]) + with self.assertRaises(AttributeError): + manipulator.add_parameter_value("a", "4") + with self.assertRaises(AttributeError): + manipulator.toggle_parameter_value("a", "5") + with self.assertRaises(AttributeError): + manipulator.remove_parameter_value("a", "5") + with self.assertRaises(AttributeError): + manipulator.remove_parameter("a") + + def test_new_modifyable_instances_do_not_conflict(self): + manipulator = QueryStringTransformer(self.test_query) + + new_qs_1 = manipulator.new() + self.assertEqual(new_qs_1.remove_parameter("b"), new_qs_1) + self.assertEqual(new_qs_1.get_query_string(), "?a=1") + + new_qs_2 = manipulator.new() + self.assertEqual(new_qs_2.get_query_string(), "?a=1&b=2&b=3") class TestTolerantQueryStringTransformer(unittest.TestCase): @@ -147,76 +194,91 @@ def __init__(self, *args, **kwargs): def test_add_parameter(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) + new_qs = manipulator.new() - self.assertEqual(manipulator.add_parameter("h", [False]), manipulator) - self.assertTrue(manipulator.parameter_exists("h")) - self.assertEqual(manipulator.parameter_values("h"), ["False"]) + self.assertEqual(new_qs.add_parameter("h", [False]), new_qs) + self.assertTrue(new_qs.parameter_exists("h")) + self.assertEqual(new_qs.parameter_values("h"), ["False"]) - self.assertEqual(manipulator.add_parameter("h", [True]), manipulator) - self.assertTrue(manipulator.parameter_exists("h")) - self.assertEqual(manipulator.parameter_values("h"), ["True"]) + self.assertEqual(new_qs.add_parameter("h", [True]), new_qs) + self.assertTrue(new_qs.parameter_exists("h")) + self.assertEqual(new_qs.parameter_values("h"), ["True"]) - self.assertEqual(manipulator.get_query_string(), "?a=1&b=2&b=3&h=True") + self.assertEqual(new_qs.get_query_string(), "?a=1&b=2&b=3&h=True") def test_update_parameter(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertEqual(manipulator.update_parameter("a", "10"), manipulator) - self.assertEqual(manipulator.parameter_values("a"), ["10"]) - self.assertEqual(manipulator.update_parameter("b", ["20", "30"]), manipulator) - self.assertEqual(manipulator.parameter_values("b"), ["20", "30"]) - self.assertEqual(manipulator.update_parameter("c", ["40"]), manipulator) - self.assertEqual(manipulator.parameter_values("c"), ["40"]) - self.assertEqual(manipulator.get_query_string(), "?a=10&b=20&b=30&c=40") - self.assertEqual(manipulator.update_parameter("c", "50"), manipulator) - self.assertEqual(manipulator.parameter_values("c"), ["50"]) - self.assertEqual(manipulator.get_query_string(), "?a=10&b=20&b=30&c=50") + new_qs = manipulator.new() + + self.assertEqual(new_qs.update_parameter("a", "10"), new_qs) + self.assertEqual(new_qs.parameter_values("a"), ["10"]) + self.assertEqual(new_qs.update_parameter("b", ["20", "30"]), new_qs) + self.assertEqual(new_qs.parameter_values("b"), ["20", "30"]) + self.assertEqual(new_qs.update_parameter("b", "100"), new_qs) + self.assertEqual(new_qs.parameter_values("b"), ["100"]) + self.assertEqual(new_qs.update_parameter("c", ["40"]), new_qs) + self.assertEqual(new_qs.parameter_values("c"), ["40"]) + self.assertEqual(new_qs.get_query_string(), "?a=10&b=100&c=40") + self.assertEqual(new_qs.update_parameter("c", "50"), new_qs) + self.assertEqual(new_qs.parameter_values("c"), ["50"]) + self.assertEqual(new_qs.get_query_string(), "?a=10&b=100&c=50") def test_remove_parameter(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertEqual(manipulator.remove_parameter("a"), manipulator) - self.assertFalse(manipulator.parameter_exists("a")) - self.assertEqual(manipulator.remove_parameter("b"), manipulator) - self.assertFalse(manipulator.parameter_exists("b")) - self.assertEqual(manipulator.remove_parameter("c"), manipulator) - self.assertEqual(manipulator.get_query_string(), "") + new_qs = manipulator.new() + + self.assertEqual(new_qs.remove_parameter("a"), new_qs) + self.assertFalse(new_qs.parameter_exists("a")) + self.assertEqual(new_qs.remove_parameter("b"), new_qs) + self.assertFalse(new_qs.parameter_exists("b")) + self.assertEqual(new_qs.remove_parameter("c"), new_qs) + self.assertEqual(new_qs.get_query_string(), "") def test_is_value_in_parameter(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertTrue(manipulator.is_value_in_parameter("a", "1")) - self.assertTrue(manipulator.is_value_in_parameter("b", "2")) - self.assertTrue(manipulator.is_value_in_parameter("b", "3")) - self.assertFalse(manipulator.is_value_in_parameter("b", "4")) - self.assertFalse(manipulator.is_value_in_parameter("c", "5")) + new_qs = manipulator.new() + + self.assertTrue(new_qs.is_value_in_parameter("a", "1")) + self.assertTrue(new_qs.is_value_in_parameter("b", "2")) + self.assertTrue(new_qs.is_value_in_parameter("b", "3")) + self.assertFalse(new_qs.is_value_in_parameter("b", "4")) + self.assertFalse(new_qs.is_value_in_parameter("c", "5")) def test_toggle_parameter_value(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertEqual(manipulator.toggle_parameter_value("a", "1"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("a", "1")) - self.assertEqual(manipulator.toggle_parameter_value("a", "10"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "10")) - self.assertEqual(manipulator.toggle_parameter_value("b", "2"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("b", "2")) - self.assertEqual(manipulator.get_query_string(), "?a=10&b=3") - self.assertEqual(manipulator.toggle_parameter_value("a", "1"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "1")) - self.assertEqual(manipulator.toggle_parameter_value("c", "4"), manipulator) - self.assertEqual(manipulator.get_query_string(), "?a=10&a=1&b=3&c=4") - self.assertEqual(manipulator.toggle_parameter_value("c", "4"), manipulator) - self.assertEqual(manipulator.get_query_string(), "?a=10&a=1&b=3") + new_qs = manipulator.new() + + self.assertEqual(new_qs.toggle_parameter_value("a", "1"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("a", "1")) + self.assertEqual(new_qs.toggle_parameter_value("a", "10"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "10")) + self.assertEqual(new_qs.toggle_parameter_value("b", "2"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("b", "2")) + self.assertEqual(new_qs.get_query_string(), "?a=10&b=3") + self.assertEqual(new_qs.toggle_parameter_value("a", "1"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "1")) + self.assertEqual(new_qs.toggle_parameter_value("c", "4"), new_qs) + self.assertEqual(new_qs.get_query_string(), "?a=10&a=1&b=3&c=4") + self.assertEqual(new_qs.toggle_parameter_value("c", "4"), new_qs) + self.assertEqual(new_qs.get_query_string(), "?a=10&a=1&b=3") def test_add_remove_parameter_value(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertEqual(manipulator.add_parameter_value("a", "10"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("a", "10")) - self.assertEqual(manipulator.parameter_values("a"), ["1", "10"]) - self.assertEqual(manipulator.add_parameter_value("c", "4"), manipulator) - self.assertTrue(manipulator.is_value_in_parameter("c", "4")) - self.assertEqual(manipulator.parameter_values("c"), ["4"]) + new_qs = manipulator.new() + + self.assertEqual(new_qs.add_parameter_value("a", "10"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("a", "10")) + self.assertEqual(new_qs.parameter_values("a"), ["1", "10"]) + self.assertEqual(new_qs.add_parameter_value("c", "4"), new_qs) + self.assertTrue(new_qs.is_value_in_parameter("c", "4")) + self.assertEqual(new_qs.parameter_values("c"), ["4"]) def test_remove_parameter_value(self): manipulator = QueryStringTransformer(self.test_query, tolerant=True) - self.assertEqual(manipulator.remove_parameter_value("b", "2"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("b", "2")) - self.assertEqual(manipulator.parameter_values("b"), ["3"]) - self.assertEqual(manipulator.remove_parameter_value("c", "4"), manipulator) - self.assertFalse(manipulator.is_value_in_parameter("c", "4")) + new_qs = manipulator.new() + + self.assertEqual(new_qs.remove_parameter_value("b", "2"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("b", "2")) + self.assertEqual(new_qs.parameter_values("b"), ["3"]) + self.assertEqual(new_qs.remove_parameter_value("c", "4"), new_qs) + self.assertFalse(new_qs.is_value_in_parameter("c", "4")) diff --git a/tna_utilities/__init__.py b/tna_utilities/__init__.py index 990d8cb..9c812f9 100644 --- a/tna_utilities/__init__.py +++ b/tna_utilities/__init__.py @@ -2,18 +2,20 @@ def strtobool(value: str) -> bool: """ Convert a string representation of truth to true (1) or false (0). - Raises TypeError if 'val' is not a string. + Raises TypeError if 'value' is not a string. True values are 'y', 'yes', 't', 'true', 'on', and '1'. False values are 'n', 'no', 'f', 'false', 'off', and '0'. - Raises ValueError if 'val' is anything else. + Raises ValueError if 'value' is anything else. """ + try: value = value.lower() except AttributeError as e: - raise TypeError(f"invalid truth value {value!r}") from e + raise TypeError(f"Invalid truth value {value!r}") from e if value in ("y", "yes", "t", "true", "on", "1"): return True if value in ("n", "no", "f", "false", "off", "0"): return False - raise ValueError(f"invalid truth value {value!r}") + + raise ValueError(f"Invalid truth value {value!r}") diff --git a/tna_utilities/component.py b/tna_utilities/component.py index 1206b6d..80a7ee1 100644 --- a/tna_utilities/component.py +++ b/tna_utilities/component.py @@ -90,3 +90,58 @@ def tna_frontend_pagination_items( (transformer(item, current_page, base_url) if type(item) is int else ellipsis) for item in paginated_items ] + + +def tna_frontend_pagination( + pages: int, + current_page: int, + base_url: str, + custom_properties: dict = None, + around: int = 1, + transformer: Callable[ + [int, int, str], dict + ] = lambda item, current_page, base_url: { + "number": item, + "current": item == current_page, + "href": f"{base_url}{item}", + }, + ellipsis: dict | None = None, + previous_page_properties: dict = None, + next_page_properties: dict = None, +) -> dict: + """ + Convert paginated items to a format suitable for the TNA frontend. + + Args: + pages (int): The total number of pages to paginate. + current_page (int): The current page number. + base_url (str): The base URL to use for pagination links. + custom_properties (dict, optional): A dictionary of additional properties to include in the returned dictionary. Defaults to an empty dictionary. + around (int, optional): The number of pages to show around the current page. Defaults to 1. + transformer (callable, optional): A function to transform each page item. + ellipsis (dict, optional): A dictionary representing the ellipsis item. Defaults to {"ellipsis": True}. + previous_page_properties (dict, optional): A dictionary of properties for the previous page link. Defaults to an empty dictionary. + next_page_properties (dict, optional): A dictionary of properties for the next page link. Defaults to an empty dictionary. + Returns: + dict: A dictionary containing the paginated items for the TNA frontend, with "items" as a list of dictionaries representing the paginated items, and a "previous" and "next" for navigation if applicable. + """ + + content = custom_properties.copy() if custom_properties else {} + + content["items"] = tna_frontend_pagination_items( + pages, current_page, base_url, around, transformer, ellipsis + ) + + if current_page > 1: + previous_page = ( + previous_page_properties.copy() if previous_page_properties else {} + ) + previous_page["href"] = f"{base_url}{current_page - 1}" + content["previous"] = previous_page + + if current_page < pages: + next_page = next_page_properties.copy() if next_page_properties else {} + next_page["href"] = f"{base_url}{current_page + 1}" + content["next"] = next_page + + return content diff --git a/tna_utilities/url.py b/tna_utilities/url.py index 1496af6..c16d278 100644 --- a/tna_utilities/url.py +++ b/tna_utilities/url.py @@ -1,9 +1,9 @@ from urllib.parse import urlencode -class QueryStringTransformer: +class BaseQueryString: """ - A utility class to manipulate query strings. + A utility class to query URL query strings. Args: args: An object representing the query parameters, typically an @@ -48,9 +48,39 @@ def parameter_values(self, parameter: str) -> list: return [] raise KeyError(f"Parameter '{parameter}' does not exist") + def is_value_in_parameter(self, parameter: str, value: str | int) -> bool: + """ + Check if a specific value exists within a parameter's values. + Raises a KeyError if the parameter does not exist and tolerant mode is not enabled. + """ + + for key, values in self.args: + if key == parameter: + return str(value) in values + if self.tolerant: + return False + raise KeyError(f"Parameter '{parameter}' does not exist") + + def get_query_string(self) -> str: + """ + Get the full query string. + Returns an empty string if there are no query parameters. + """ + + query = urlencode(self.args, doseq=True) + if not query: + return "" + return f"?{query}" + + +class QueryStringModifier(BaseQueryString): + """ + A utility class to manipulate URL query strings. + """ + def add_parameter( self, parameter: str, values: str | int | list | None = None - ) -> "QueryStringTransformer": + ) -> "QueryStringModifier": """ Add a new parameter to the query parameters. Raises a ValueError if the parameter already exists and tolerant mode is not enabled. @@ -71,7 +101,7 @@ def add_parameter( def update_parameter( self, parameter: str, values: str | int | list | None = None - ) -> "QueryStringTransformer": + ) -> "QueryStringModifier": """ Update an existing parameter in the query parameters. If the parameter does not exist, it will be added. @@ -82,7 +112,7 @@ def update_parameter( self.add_parameter(parameter, values) return self - def remove_parameter(self, parameter: str) -> "QueryStringTransformer": + def remove_parameter(self, parameter: str) -> "QueryStringModifier": """ Remove a parameter from the query parameters. Raises a KeyError if the parameter does not exist and tolerant mode is not enabled. @@ -96,22 +126,9 @@ def remove_parameter(self, parameter: str) -> "QueryStringTransformer": return self raise KeyError(f"Parameter '{parameter}' does not exist") - def is_value_in_parameter(self, parameter: str, value: str | int) -> bool: - """ - Check if a specific value exists within a parameter's values. - Raises a KeyError if the parameter does not exist and tolerant mode is not enabled. - """ - - for key, values in self.args: - if key == parameter: - return str(value) in values - if self.tolerant: - return False - raise KeyError(f"Parameter '{parameter}' does not exist") - def add_parameter_value( self, parameter: str, value: str | int - ) -> "QueryStringTransformer": + ) -> "QueryStringModifier": """ Add a specific value to a parameter's values. Raises a KeyError if the parameter does not exist and tolerant mode is not enabled. @@ -129,7 +146,7 @@ def add_parameter_value( def toggle_parameter_value( self, parameter: str, value: str | int - ) -> "QueryStringTransformer": + ) -> "QueryStringModifier": """ Toggle a value within a parameter's values. If the value exists, it will be removed; if it does not exist, it will be added. @@ -151,7 +168,7 @@ def toggle_parameter_value( def remove_parameter_value( self, parameter: str, value: str | int - ) -> "QueryStringTransformer": + ) -> "QueryStringModifier": """ Remove a specific value from a parameter's values. Raises a KeyError if the parameter does not exist or if the value is not present and tolerant mode is not enabled. @@ -169,13 +186,26 @@ def remove_parameter_value( return self raise KeyError(f"Parameter '{parameter}' does not exist") - def get_query_string(self) -> str: + +class QueryStringTransformer(BaseQueryString): + """ + A utility class that extends QueryStringModifier to manipulate query strings while keeping a copy of the initial query string intact for querying later. + + Args: + args: An object representing the query parameters, typically an + ImmutableMultiDict (Django) or QueryDict (Flask) which can be + accessed with request.GET (Django) or request.args (Flask). + tolerant: If True, the transformer will not raise exceptions when + keys don't exist. + """ + + def __init__(self, args=None, tolerant=False) -> None: + super().__init__(args, tolerant) + self.initial_args = self.args + + def new(self) -> "QueryStringModifier": """ - Get the full query string. - Returns an empty string if there are no query parameters. + Create a new instance of QueryStringModifier with the same initial query string. """ - query = urlencode(self.args, doseq=True) - if not query: - return "" - return f"?{query}" + return QueryStringModifier(self.initial_args.copy(), self.tolerant)