From a6b6b41301507f524a9cc3f7bc159c6ae9cafff1 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 02:52:43 +0000 Subject: [PATCH 1/5] feat: create validators utility --- openproficiency/validators.py | 57 +++ tests/validators_test.py | 689 ++++++++++++++++++++++++++++++++++ 2 files changed, 746 insertions(+) create mode 100644 openproficiency/validators.py create mode 100644 tests/validators_test.py diff --git a/openproficiency/validators.py b/openproficiency/validators.py new file mode 100644 index 0000000..fd80e49 --- /dev/null +++ b/openproficiency/validators.py @@ -0,0 +1,57 @@ +"""Validation utilities for OpenProficiency library.""" + +import re + + +def validate_kebab_case(value: str) -> None: + """ + Validate that a string follows kebab-case format. + + Pattern: lowercase alphanumeric characters with hyphens as separators. + Examples: "topic", "topic-id", "math-level-1" + + Args: + value: The string to validate + + Raises: + ValueError: If the value is not in valid kebab-case format + """ + if not value: + raise ValueError("Value cannot be empty") + + # Pattern: starts and ends with alphanumeric, can have hyphens between + pattern = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" + + if not re.match(pattern, value): + raise ValueError( + f"Value must be in kebab-case format (lowercase alphanumeric with hyphens). " + f"Got: '{value}'. Examples: 'topic', 'topic-id', 'math-level-1'" + ) + + +def validate_hostname(value: str) -> None: + """ + Validate that a string is a valid hostname or domain name. + + Pattern: lowercase alphanumeric characters with hyphens and dots as separators. + Examples: "github", "example.com", "sub.example.com" + + Args: + value: The string to validate + + Raises: + ValueError: If the value is not in valid hostname format + """ + if not value: + raise ValueError("Value cannot be empty") + + # Pattern: hostname components separated by dots + # Each component: starts and ends with alphanumeric, can have hyphens between + component_pattern = r"[a-z0-9]+(?:-[a-z0-9]+)*" + pattern = f"^{component_pattern}(?:\\.{component_pattern})*$" + + if not re.match(pattern, value): + raise ValueError( + f"Value must be a valid hostname (lowercase alphanumeric with hyphens and dots). " + f"Got: '{value}'. Examples: 'github', 'example.com', 'acme-corp'" + ) diff --git a/tests/validators_test.py b/tests/validators_test.py new file mode 100644 index 0000000..6732081 --- /dev/null +++ b/tests/validators_test.py @@ -0,0 +1,689 @@ +from openproficiency.validators import validate_kebab_case, validate_hostname + + +class TestValidateKebabCase: + + # Valid kebab-case strings + def test_valid_single_word(self): + """Single lowercase word should be valid.""" + + # Arrange + value = "topic" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_two_words(self): + """Two words with hyphen should be valid.""" + + # Arrange + value = "topic-id" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_multiple_words(self): + """Multiple words with hyphens should be valid.""" + + # Arrange + value = "multi-part-topic-name" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_with_numbers(self): + """Kebab-case with numbers should be valid.""" + + # Arrange + value = "math-level-1" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_starting_with_number(self): + """Starting with a number should be valid.""" + + # Arrange + value = "3d-graphics" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_ending_with_number(self): + """Ending with a number should be valid.""" + + # Arrange + value = "python-3" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_only_numbers(self): + """Only numbers should be valid.""" + + # Arrange + value = "123" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + def test_valid_numbers_with_hyphens(self): + """Numbers with hyphens should be valid.""" + + # Arrange + value = "123-456" + + # Act + validate_kebab_case(value) + + # Assert + # No exception raised + + # Invalid kebab-case strings + def test_invalid_empty_string(self): + """Empty string should raise ValueError.""" + + # Arrange + value = "" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "cannot be empty" in str(exception_raised) + + def test_invalid_uppercase_letter(self): + """Uppercase letters should raise ValueError.""" + + # Arrange + value = "Topic" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_mixed_case(self): + """Mixed case should raise ValueError.""" + + # Arrange + value = "Topic-Id" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_underscore(self): + """Underscores should raise ValueError.""" + + # Arrange + value = "topic_id" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_double_hyphen(self): + """Double hyphens should raise ValueError.""" + + # Arrange + value = "topic--id" + + # Act + + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_leading_hyphen(self): + """Leading hyphen should raise ValueError.""" + + # Arrange + value = "-topic" + + # Act + + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_trailing_hyphen(self): + """Trailing hyphen should raise ValueError.""" + + # Arrange + value = "topic-" + + # Act + + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_space(self): + """Spaces should raise ValueError.""" + + # Arrange + value = "topic id" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_special_characters(self): + """Special characters should raise ValueError.""" + + # Arrange + value = "topic@id" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_dot(self): + """Dots should raise ValueError.""" + + # Arrange + value = "topic.id" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "kebab-case" in str(exception_raised) + + def test_invalid_error_message_structure(self): + """Error message should contain value that was rejected.""" + + # Arrange + value = "Invalid" + + # Act + exception_raised = None + try: + validate_kebab_case(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "Invalid" in str(exception_raised) + + +class TestValidateHostname: + + # Valid hostnames + def test_valid_single_word(self): + """Single lowercase word should be valid.""" + + # Arrange + value = "github" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_with_hyphen(self): + """Kebab-case hostname should be valid.""" + + # Arrange + value = "acme-corp" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_simple_domain(self): + """Simple domain should be valid.""" + + # Arrange + value = "example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_subdomain(self): + """Subdomain should be valid.""" + + # Arrange + value = "sub.example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_multiple_subdomains(self): + """Multiple subdomains should be valid.""" + + # Arrange + value = "a.b.c.example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_with_numbers(self): + """Hostnames with numbers should be valid.""" + + # Arrange + value = "server-1.example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_starting_with_number(self): + """Components starting with numbers should be valid.""" + + # Arrange + value = "1server.example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_ending_with_number(self): + """Components ending with numbers should be valid.""" + + # Arrange + value = "server1.example.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_only_numbers_in_component(self): + """Numeric-only components should be valid.""" + + # Arrange + value = "123.456.789" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + def test_valid_hyphenated_subdomain(self): + """Hyphenated subdomains should be valid.""" + + # Arrange + value = "my-server.my-domain.com" + + # Act + validate_hostname(value) + + # Assert + # No exception raised + + # Invalid hostnames + def test_invalid_empty_string(self): + """Empty string should raise ValueError.""" + + # Arrange + value = "" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "cannot be empty" in str(exception_raised) + + def test_invalid_uppercase_letter(self): + """Uppercase letters should raise ValueError.""" + + # Arrange + value = "GitHub" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_mixed_case(self): + """Mixed case should raise ValueError.""" + + # Arrange + value = "Example.Com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_underscore(self): + """Underscores should raise ValueError.""" + + # Arrange + value = "my_server" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_double_hyphen(self): + """Double hyphens should raise ValueError.""" + + # Arrange + value = "my--server" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_leading_hyphen(self): + """Leading hyphen should raise ValueError.""" + + # Arrange + value = "-github" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_trailing_hyphen(self): + """Trailing hyphen should raise ValueError.""" + + # Arrange + value = "github-" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_leading_hyphen_in_component(self): + """Leading hyphen in component should raise ValueError.""" + + # Arrange + value = "example.-com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_trailing_hyphen_in_component(self): + """Trailing hyphen in component should raise ValueError.""" + + # Arrange + value = "example-.com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_leading_dot(self): + """Leading dot should raise ValueError.""" + + # Arrange + value = ".example.com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_trailing_dot(self): + """Trailing dot should raise ValueError.""" + + # Arrange + value = "example.com." + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_double_dot(self): + """Double dots should raise ValueError.""" + + # Arrange + value = "example..com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_space(self): + """Spaces should raise ValueError.""" + + # Arrange + value = "my server" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_special_characters(self): + """Special characters should raise ValueError.""" + + # Arrange + value = "example@com" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_path_separator(self): + """Path separators should raise ValueError.""" + + # Arrange + value = "example.com/path" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "hostname" in str(exception_raised) + + def test_invalid_error_message_structure(self): + """Error message should contain value that was rejected.""" + + # Arrange + value = "Invalid_Host" + + # Act + exception_raised = None + try: + validate_hostname(value) + except Exception as e: + exception_raised = e + + # Assert + assert isinstance(exception_raised, ValueError) + assert "Invalid_Host" in str(exception_raised) From 9761ae95f12b28d7229d23982b113e11e88ce518 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 02:59:20 +0000 Subject: [PATCH 2/5] feat: validate Topic ID is kebab case --- openproficiency/Topic.py | 13 +++ tests/Topic_test.py | 210 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/openproficiency/Topic.py b/openproficiency/Topic.py index 85d4772..a6eac07 100644 --- a/openproficiency/Topic.py +++ b/openproficiency/Topic.py @@ -2,6 +2,7 @@ import json from typing import List, Union +from .validators import validate_kebab_case class Topic: @@ -28,6 +29,18 @@ def __init__( self.add_subtopics(subtopics) self.add_pretopics(pretopics) + # Properties + @property + def id(self) -> str: + """Get the topic ID.""" + return self._id + + @id.setter + def id(self, value: str) -> None: + """Set the topic ID. kebab-case""" + validate_kebab_case(value) + self._id = value + # Methods def add_subtopic(self, subtopic: Union[str, "Topic"]) -> None: """ diff --git a/tests/Topic_test.py b/tests/Topic_test.py index fcff1fe..6606925 100644 --- a/tests/Topic_test.py +++ b/tests/Topic_test.py @@ -232,3 +232,213 @@ def test_topic_repr(self): assert "git" in repr_str assert "Git version control" in repr_str assert "Topic" in repr_str + + +class TestTopicIdentifierValidation: + """Tests for Topic ID kebab-case validation.""" + + # Valid identifiers + def test_valid_single_word(self): + """Test that single word lowercase IDs are valid.""" + + # Arrange + id = "topic" + + # Act + topic = Topic(id=id) + + # Assert + assert topic.id == id + + def test_valid_kebab_case(self): + """Test that kebab-case IDs are valid.""" + + # Arrange + id = "topic-id" + + # Act + topic = Topic(id=id) + + # Assert + assert topic.id == id + + def test_valid_with_numbers(self): + """Test that kebab-case with numbers is valid.""" + + # Arrange + id = "math-level-1" + + # Act + topic = Topic(id=id) + + # Assert + assert topic.id == id + + def test_valid_multiple_hyphens(self): + """Test that multiple hyphen-separated parts are valid.""" + + # Arrange + id = "multi-part-topic-name" + + # Act + topic = Topic(id=id) + + # Assert + assert topic.id == id + + # Invalid identifiers - construction time + def test_invalid_uppercase_construction(self): + """Test that uppercase letters are rejected during construction.""" + + # Arrange + invalid_id = "Topic" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_mixed_case_construction(self): + """Test that mixed case is rejected during construction.""" + + # Arrange + invalid_id = "Topic-Id" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_underscore_construction(self): + """Test that underscores are rejected during construction.""" + + # Arrange + invalid_id = "topic_id" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_double_hyphen_construction(self): + """Test that double hyphens are rejected during construction.""" + + # Arrange + invalid_id = "topic--id" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_leading_hyphen_construction(self): + """Test that leading hyphens are rejected during construction.""" + + # Arrange + invalid_id = "-topic" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_trailing_hyphen_construction(self): + """Test that trailing hyphens are rejected during construction.""" + + # Arrange + invalid_id = "topic-" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_empty_string_construction(self): + """Test that empty strings are rejected during construction.""" + + # Arrange + invalid_id = "" + + # Act + result = None + try: + Topic(id=invalid_id) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "cannot be empty" in str(result) + + # Invalid identifiers - property update + def test_invalid_uppercase_update(self): + """Test that uppercase letters are rejected during property update.""" + + # Arrange + topic = Topic(id="valid-id") + invalid_id = "Invalid" + + # Act + result = None + try: + topic.id = invalid_id + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_underscore_update(self): + """Test that underscores are rejected during property update.""" + + # Arrange + topic = Topic(id="valid-id") + invalid_id = "invalid_id" + + # Act + result = None + try: + topic.id = invalid_id + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) From 5d4d2a9b1cb0e6d06317bfcfc35e020130b661c8 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 03:05:53 +0000 Subject: [PATCH 3/5] fix: hostname passing with no top level domain --- openproficiency/validators.py | 8 ++++---- tests/validators_test.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openproficiency/validators.py b/openproficiency/validators.py index fd80e49..153e06f 100644 --- a/openproficiency/validators.py +++ b/openproficiency/validators.py @@ -34,7 +34,7 @@ def validate_hostname(value: str) -> None: Validate that a string is a valid hostname or domain name. Pattern: lowercase alphanumeric characters with hyphens and dots as separators. - Examples: "github", "example.com", "sub.example.com" + Examples: "example.com", "sub.example.com" Args: value: The string to validate @@ -45,13 +45,13 @@ def validate_hostname(value: str) -> None: if not value: raise ValueError("Value cannot be empty") - # Pattern: hostname components separated by dots + # Pattern: hostname components separated by dots (requires at least 2 components) # Each component: starts and ends with alphanumeric, can have hyphens between component_pattern = r"[a-z0-9]+(?:-[a-z0-9]+)*" - pattern = f"^{component_pattern}(?:\\.{component_pattern})*$" + pattern = f"^{component_pattern}(?:\\.{component_pattern})+$" if not re.match(pattern, value): raise ValueError( f"Value must be a valid hostname (lowercase alphanumeric with hyphens and dots). " - f"Got: '{value}'. Examples: 'github', 'example.com', 'acme-corp'" + f"Got: '{value}'. Examples: 'example.com','sub.example.com'" ) diff --git a/tests/validators_test.py b/tests/validators_test.py index 6732081..a95a0a4 100644 --- a/tests/validators_test.py +++ b/tests/validators_test.py @@ -296,10 +296,10 @@ class TestValidateHostname: # Valid hostnames def test_valid_single_word(self): - """Single lowercase word should be valid.""" + """Simple domain name should be valid.""" # Arrange - value = "github" + value = "example.com" # Act validate_hostname(value) @@ -308,10 +308,10 @@ def test_valid_single_word(self): # No exception raised def test_valid_with_hyphen(self): - """Kebab-case hostname should be valid.""" + """Kebab-case hostname with TLD should be valid.""" # Arrange - value = "acme-corp" + value = "acme-corp.com" # Act validate_hostname(value) @@ -505,7 +505,7 @@ def test_invalid_leading_hyphen(self): """Leading hyphen should raise ValueError.""" # Arrange - value = "-github" + value = "-example" # Act exception_raised = None @@ -522,7 +522,7 @@ def test_invalid_trailing_hyphen(self): """Trailing hyphen should raise ValueError.""" # Arrange - value = "github-" + value = "example-" # Act exception_raised = None From 219f84d324a63e399043e666393c9df7d06fb652 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 03:13:49 +0000 Subject: [PATCH 4/5] feat: validate topiclist owner format and name format --- openproficiency/TopicList.py | 23 ++ tests/TopicList_test.py | 516 ++++++++++++++++++++++++++++++----- 2 files changed, 471 insertions(+), 68 deletions(-) diff --git a/openproficiency/TopicList.py b/openproficiency/TopicList.py index 56c3252..aca92b4 100644 --- a/openproficiency/TopicList.py +++ b/openproficiency/TopicList.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import Dict, Any, Union, List, cast from .Topic import Topic +from .validators import validate_kebab_case, validate_hostname class TopicList: @@ -61,6 +62,28 @@ def get_topic(self, topic_id: str) -> Union[Topic, None]: return self.topics.get(topic_id, None) # Properties + @property + def owner(self) -> str: + """Get the owner name.""" + return self._owner + + @owner.setter + def owner(self, value: str) -> None: + """Set the owner with hostname validation. Format: hostname, Ex: `example.com`""" + validate_hostname(value) + self._owner = value + + @property + def name(self) -> str: + """Get the TopicList name. Format: kebab-case""" + return self._name + + @name.setter + def name(self, value: str) -> None: + """Set the TopicList name with kebab-case validation.""" + validate_kebab_case(value) + self._name = value + @property def version(self) -> Union[str, None]: """Get the semantic version of the TopicList.""" diff --git a/tests/TopicList_test.py b/tests/TopicList_test.py index a7dea9b..0772c5a 100644 --- a/tests/TopicList_test.py +++ b/tests/TopicList_test.py @@ -12,8 +12,8 @@ def test_init_required_params(self): """Create a topic list with required.""" # Arrange - owner = "github" - name = "github" + owner = "example.com" + name = "example-topics-list" # Act topic_list = TopicList( @@ -31,8 +31,8 @@ def test_init_with_description(self): """Create a topic list with description field.""" # Arrange - owner = "github" - name = "github" + owner = "example.com" + name = "example-topics-list" description = "Features of the GitHub platform" # Act @@ -51,8 +51,8 @@ def test_init_with_version(self): """Create a topic list with custom version.""" # Arrange - owner = "github" - name = "github" + owner = "example.com" + name = "example-topics-list" version = "2.1.0" # Act @@ -69,8 +69,8 @@ def test_init_with_timestamp(self): """Create a topic list with custom timestamp.""" # Arrange - owner = "github" - name = "github" + owner = "example.com" + name = "example-topics-list" timestamp = "2025-01-15T10:30:00+00:00" # Act @@ -87,8 +87,8 @@ def test_init_with_certificate(self): """Create a topic list with custom certificate.""" # Arrange - owner = "github" - name = "github" + owner = "example.com" + name = "example-topics-list" certificate = "cert-12345" # Act @@ -107,8 +107,8 @@ def test_init_timestamp_defaults_to_current_utc(self): # Act topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Assert @@ -122,8 +122,8 @@ def test_version_default(self): # Act topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Assert @@ -135,8 +135,8 @@ def test_add_topic_string(self): # Arrange topic_list = TopicList( - owner="github", - name="git", + owner="example.com", + name="example-topics-list", ) topic_id = "git-commit" @@ -152,8 +152,8 @@ def test_add_topic_topic(self): # Arrange topic_list = TopicList( - owner="github", - name="git", + owner="example.com", + name="example-topics-list", ) topic1 = Topic( id="git-commit", @@ -172,8 +172,8 @@ def test_get_topic(self): # Arrange topic_list = TopicList( - owner="github", - name="git", + owner="example.com", + name="example-topics-list", ) topic = Topic(id="git-commit") topic_list.topics[topic.id] = topic @@ -190,8 +190,8 @@ def test_get_topic_nonexistent(self): # Arrange topic_list = TopicList( - owner="github", - name="git", + owner="example.com", + name="example-topics-list", ) topic = Topic(id="git-commit") topic_list.topics[topic.id] = topic @@ -207,8 +207,8 @@ def test_full_name(self): """Test getting the full name of the topic list.""" # Arrange - owner = "github" - name = "git" + owner = "example.com" + name = "example-topic-list" topic_list = TopicList( owner=owner, name=name, @@ -218,15 +218,15 @@ def test_full_name(self): full_name = topic_list.full_name # Assert - assert full_name == "github/git" + assert full_name == "example.com/example-topic-list" def test_version_setter_valid_format(self): """Test setting version with valid semantic versioning.""" # Arrange topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Act @@ -240,8 +240,8 @@ def test_version_setter_invalid_format_missing_patch(self): # Arrange topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Act & Assert @@ -257,8 +257,8 @@ def test_version_setter_invalid_format_extra_component(self): # Arrange topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Act @@ -278,8 +278,8 @@ def test_version_setter_invalid_format_non_numeric(self): # Arrange topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Act @@ -299,8 +299,8 @@ def test_version_zero_values(self): # Arrange topic_list = TopicList( - owner="github", - name="github", + owner="example.com", + name="example", ) # Act @@ -316,8 +316,8 @@ def test_load_from_json_basic_info(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features" + "owner": "example.com", + "name": "example-features" } """ @@ -325,8 +325,8 @@ def test_load_from_json_basic_info(self): topic_list = TopicList.from_json(json_data) # Assert - list details - assert topic_list.owner == "github" - assert topic_list.name == "github-features" + assert topic_list.owner == "example.com" + assert topic_list.name == "example-features" def test_load_from_json_optional_inputs(self): """Load a list with version, timestamp, and certificate fields.""" @@ -334,8 +334,8 @@ def test_load_from_json_optional_inputs(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features", + "owner": "example.com", + "name": "example-features", "description": "Features of the GitHub platform", "version": "2.3.1", "timestamp": "2025-01-15T10:30:00+00:00", @@ -357,8 +357,8 @@ def test_load_from_json_simple(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features", + "owner": "example.com", + "name": "example-features", "description": "Features of the GitHub platform", "topics": { "actions": { @@ -387,8 +387,8 @@ def test_load_from_json_subtopics(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features", + "owner": "example.com", + "name": "example-features", "description": "Features of the GitHub platform", "topics": { @@ -433,8 +433,8 @@ def test_load_from_json_subsubtopics(self): # Arrange json_data = """ { - "owner": "github", - "name": "github", + "owner": "example.com", + "name": "example", "description": "Features of the GitHub platform", "topics": { @@ -479,8 +479,8 @@ def test_load_from_json_pretopics(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features", + "owner": "example.com", + "name": "example-features", "description": "Features of the GitHub platform", "topics": { @@ -525,8 +525,8 @@ def test_load_from_json_prepretopics(self): # Arrange json_data = """ { - "owner": "github", - "name": "github-features", + "owner": "example.com", + "name": "example-features", "description": "Features of the GitHub platform", "topics": { @@ -572,8 +572,8 @@ def test_to_dict_simple(self): # Arrange topic_list = TopicList( - owner="github", - name="github-features", + owner="example.com", + name="example-features", description="Features of the GitHub platform", ) topic_list.add_topic( @@ -597,8 +597,8 @@ def test_to_dict_simple(self): data = topic_list.to_dict() # Assert - List Info - assert data["owner"] == "github" - assert data["name"] == "github-features" + assert data["owner"] == "example.com" + assert data["name"] == "example-features" assert data["description"] == "Features of the GitHub platform" # Assert - Topic 1 @@ -618,8 +618,8 @@ def test_to_dict(self): # Arrange topic_list = TopicList( - owner="github", - name="github-features", + owner="example.com", + name="example-features", ) # Act @@ -635,8 +635,8 @@ def test_to_dict_with_optionals(self): # Arrange topic_list = TopicList( - owner="github", - name="github-features", + owner="example.com", + name="example-features", description="Features of the GitHub platform", version="2.1.0", timestamp="2025-01-15T10:30:00+00:00", @@ -656,8 +656,8 @@ def test_json_roundtrip_integrity(self): # Arrange original = TopicList( - owner="github", - name="github-features", + owner="example.com", + name="example-features", description="Test description", version="1.2.3", timestamp="2025-02-16T14:25:00+00:00", @@ -681,8 +681,8 @@ def test_to_json_simple(self): # Arrange topic_list = TopicList( - owner="github", - name="github-features", + owner="example.com", + name="example-features", description="Features of the GitHub platform", ) topic_list.add_topic( @@ -707,8 +707,8 @@ def test_to_json_simple(self): data = json.loads(json_data) # Assert - List Info - assert data["owner"] == "github" - assert data["name"] == "github-features" + assert data["owner"] == "example.com" + assert data["name"] == "example-features" assert data["description"] == "Features of the GitHub platform" # Assert - Topic 1 @@ -759,7 +759,10 @@ def test_repr(self): """Test string representation of TopicList.""" # Arrange - topic_list = TopicList(owner="github", name="git") + topic_list = TopicList( + owner="example.com", + name="example-topics-list", + ) topic_list.add_topic("git-commit") topic_list.add_topic("git-push") @@ -768,6 +771,383 @@ def test_repr(self): # Assert assert "TopicList" in repr_str - assert "github" in repr_str - assert "git" in repr_str + assert "example.com" in repr_str + assert "example-topics-list" in repr_str assert "topics_count=2" in repr_str + + +class TestTopicListIdentifierValidation: + """Tests for TopicList name and owner validation.""" + + # Valid TopicList names + def test_valid_name_single_word(self): + """Test that single word lowercase names are valid.""" + + # Arrange + owner = "example.com" + name = "example-topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.name == name + + def test_valid_name_kebab_case(self): + """Test that kebab-case names are valid.""" + + # Arrange + owner = "example.com" + name = "topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.name == name + + def test_valid_name_with_numbers(self): + """Test that kebab-case names with numbers are valid.""" + + # Arrange + owner = "example.com" + name = "python-3" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.name == name + + # Invalid TopicList names - construction time + def test_invalid_name_uppercase_construction(self): + """Test that uppercase letters in name are rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "GitHub" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_name_underscore_construction(self): + """Test that underscores in name are rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "topic_list" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_name_double_hyphen_construction(self): + """Test that double hyphens in name are rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "topic--list" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_name_leading_hyphen_construction(self): + """Test that leading hyphens in name are rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "-topic" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_name_trailing_hyphen_construction(self): + """Test that trailing hyphens in name are rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "topic-" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + def test_invalid_name_empty_construction(self): + """Test that empty name is rejected during construction.""" + + # Arrange + owner = "example.com" + invalid_name = "" + + # Act + result = None + try: + TopicList(owner=owner, name=invalid_name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "cannot be empty" in str(result) + + # Invalid TopicList names - property update + def test_invalid_name_update(self): + """Test that invalid names are rejected during property update.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="valid-name", + ) + invalid_name = "Invalid_Name" + + # Act + result = None + try: + topic_list.name = invalid_name + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "kebab-case" in str(result) + + # Valid owners + def test_valid_owner_single_word(self): + """Test that single word lowercase owners are valid.""" + + # Arrange + owner = "example.com" + name = "example-topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.owner == owner + + def test_valid_owner_with_hyphen(self): + """Test that kebab-case owners are valid.""" + + # Arrange + owner = "acme-corp.com" + name = "example-topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.owner == owner + + def test_valid_owner_domain(self): + """Test that domain-style owners are valid.""" + + # Arrange + owner = "example.com" + name = "example-topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.owner == owner + + def test_valid_owner_subdomain(self): + """Test that subdomain-style owners are valid.""" + + # Arrange + owner = "sub.example.com" + name = "example-topic-list" + + # Act + topic_list = TopicList(owner=owner, name=name) + + # Assert + assert topic_list.owner == owner + + # Invalid owners - construction time + def test_invalid_owner_uppercase_construction(self): + """Test that uppercase letters in owner are rejected during construction.""" + + # Arrange + invalid_owner = "GitHub" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_underscore_construction(self): + """Test that underscores in owner are rejected during construction.""" + + # Arrange + invalid_owner = "example_org" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_leading_hyphen_construction(self): + """Test that leading hyphens in owner are rejected during construction.""" + + # Arrange + invalid_owner = "-example" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_trailing_hyphen_construction(self): + """Test that trailing hyphens in owner are rejected during construction.""" + + # Arrange + invalid_owner = "example-" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_leading_dot_construction(self): + """Test that leading dots in owner are rejected during construction.""" + + # Arrange + invalid_owner = ".example.com" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_trailing_dot_construction(self): + """Test that trailing dots in owner are rejected during construction.""" + + # Arrange + invalid_owner = "example.com." + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) + + def test_invalid_owner_empty_construction(self): + """Test that empty owner is rejected during construction.""" + + # Arrange + invalid_owner = "" + name = "example-topic-list" + + # Act + result = None + try: + TopicList(owner=invalid_owner, name=name) + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "cannot be empty" in str(result) + + # Invalid owners - property update + def test_invalid_owner_update(self): + """Test that invalid owners are rejected during property update.""" + + # Arrange + topic_list = TopicList( + owner="example.com", + name="example-topics-list", + ) + invalid_owner = "Invalid_Owner" + + # Act + result = None + try: + topic_list.owner = invalid_owner + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "hostname" in str(result) From 2ed312e09b2cfbffad926346d50a3a3e2906100c Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 03:21:32 +0000 Subject: [PATCH 5/5] feat: validate topic_id format in ProficiencyScore --- openproficiency/ProficiencyScore.py | 14 +++++++++++++- tests/ProficiencyScore_test.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/openproficiency/ProficiencyScore.py b/openproficiency/ProficiencyScore.py index d322104..55949a4 100644 --- a/openproficiency/ProficiencyScore.py +++ b/openproficiency/ProficiencyScore.py @@ -3,6 +3,7 @@ import json from enum import Enum from typing import Union +from .validators import validate_kebab_case class ProficiencyScoreName(Enum): @@ -29,7 +30,18 @@ def __init__( self.topic_id = topic_id self.score = score - # Properties - Score + # Properties + @property + def topic_id(self) -> str: + """Get the topic ID.""" + return self._topic_id + + @topic_id.setter + def topic_id(self, value: str) -> None: + """Set the topic ID. kebab-case""" + validate_kebab_case(value) + self._topic_id = value + @property def score(self) -> float: """Get the score as a numeric value between 0.0 and 1.0.""" diff --git a/tests/ProficiencyScore_test.py b/tests/ProficiencyScore_test.py index 46b49de..8f5996a 100644 --- a/tests/ProficiencyScore_test.py +++ b/tests/ProficiencyScore_test.py @@ -94,6 +94,22 @@ def test_init_invalid_score_type(self): assert "ProficiencyScoreName" in str(result) # Properties + def test_topic_id(self): + """Update the topic id and read it back.""" + + # Arrange + ps = ProficiencyScore( + topic_id="git-commit", + score=0.1, + ) + updated_topic_id = "git-merge" + + # Act + ps.topic_id = updated_topic_id + + # Assert + assert ps.topic_id == updated_topic_id + def test_score_numeric(self): """Test setting score with numeric value."""