From fc71174554ceb2b0534a9d49b1d4b3d5aa99a9a1 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Mon, 16 Feb 2026 02:17:19 +0000 Subject: [PATCH] feat: add fields to topic list, version, timestamp, certificate --- openproficiency/TopicList.py | 65 ++++++++- tests/TopicList_test.py | 266 ++++++++++++++++++++++++++++++++++- 2 files changed, 323 insertions(+), 8 deletions(-) diff --git a/openproficiency/TopicList.py b/openproficiency/TopicList.py index 1e226d4..56c3252 100644 --- a/openproficiency/TopicList.py +++ b/openproficiency/TopicList.py @@ -1,6 +1,8 @@ """TopicList module for OpenProficiency library.""" import json +import re +from datetime import datetime, timezone from typing import Dict, Any, Union, List, cast from .Topic import Topic @@ -14,7 +16,10 @@ def __init__( owner: str, name: str, # Optional - description: str = "", + description: Union[str, None] = None, + version: Union[str, None] = None, + timestamp: Union[str, None] = None, + certificate: Union[str, None] = None, ): # Required self.owner = owner @@ -25,6 +30,18 @@ def __init__( self.topics: Dict[str, Topic] = {} self.dependencies: Dict[str, "TopicList"] = {} + # Set version with validation + self.version = version + + # Set timestamp (convert string to datetime, default to current UTC if not provided) + if timestamp is None: + self._timestamp = datetime.now(timezone.utc) + else: + self._timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + + # Set certificate + self.certificate = certificate + # Methods def add_topic(self, topic: Union[str, Topic]) -> Topic: """ @@ -44,6 +61,33 @@ def get_topic(self, topic_id: str) -> Union[Topic, None]: return self.topics.get(topic_id, None) # Properties + @property + def version(self) -> Union[str, None]: + """Get the semantic version of the TopicList.""" + return self._version + + @version.setter + def version(self, value: Union[str, None]) -> None: + """Set the semantic version with X.Y.Z format validation.""" + if value is not None and not re.match(r"^\d+\.\d+\.\d+$", value): + raise ValueError(f"Invalid version format: '{value}'. Must be semantic versioning (X.Y.Z)") + self._version = value + + @property + def timestamp(self) -> datetime: + """Get the timestamp as a datetime object.""" + return self._timestamp + + @timestamp.setter + def timestamp(self, value: Union[str, datetime, None]) -> None: + """Set the timestamp from a string or datetime object.""" + if value is None: + self._timestamp = datetime.now(timezone.utc) + elif isinstance(value, str): + self._timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + self._timestamp = value + @property def full_name(self) -> str: """Get the full name of the TopicList in 'owner/name' format.""" @@ -74,7 +118,10 @@ def from_json(cls, json_data: str) -> "TopicList": topic_list = TopicList( owner=data.get("owner", ""), name=data.get("name", ""), - description=data.get("description", ""), + description=data.get("description"), + version=data.get("version"), + timestamp=data.get("timestamp"), + certificate=data.get("certificate"), ) # Add each topic @@ -211,10 +258,22 @@ def to_dict(self) -> Dict[str, Any]: data: Dict[str, Any] = { "owner": self.owner, "name": self.name, - "description": self.description, + "timestamp": self.timestamp.isoformat(), "topics": {}, } + # Add description if set + if self.description is not None: + data["description"] = self.description + + # Add version if set + if self.version is not None: + data["version"] = self.version + + # Add certificate if set + if self.certificate is not None: + data["certificate"] = self.certificate + # Add each topic for topic_id, topic in self.topics.items(): # Create topic diff --git a/tests/TopicList_test.py b/tests/TopicList_test.py index ffbc573..a7dea9b 100644 --- a/tests/TopicList_test.py +++ b/tests/TopicList_test.py @@ -1,6 +1,7 @@ """Tests for the TopicList class.""" import json +from datetime import datetime from openproficiency import Topic, TopicList @@ -26,8 +27,8 @@ def test_init_required_params(self): assert topic_list.topics == {} assert topic_list.dependencies == {} - def test_init_optional_params(self): - """Create a topic list with optional details.""" + def test_init_with_description(self): + """Create a topic list with description field.""" # Arrange owner = "github" @@ -46,6 +47,88 @@ def test_init_optional_params(self): assert topic_list.name == name assert topic_list.description == description + def test_init_with_version(self): + """Create a topic list with custom version.""" + + # Arrange + owner = "github" + name = "github" + version = "2.1.0" + + # Act + topic_list = TopicList( + owner=owner, + name=name, + version=version, + ) + + # Assert + assert topic_list.version == version + + def test_init_with_timestamp(self): + """Create a topic list with custom timestamp.""" + + # Arrange + owner = "github" + name = "github" + timestamp = "2025-01-15T10:30:00+00:00" + + # Act + topic_list = TopicList( + owner=owner, + name=name, + timestamp=timestamp, + ) + + # Assert + assert topic_list.timestamp == datetime.fromisoformat(timestamp) + + def test_init_with_certificate(self): + """Create a topic list with custom certificate.""" + + # Arrange + owner = "github" + name = "github" + certificate = "cert-12345" + + # Act + topic_list = TopicList( + owner=owner, + name=name, + certificate=certificate, + ) + + # Assert + assert topic_list.certificate == certificate + print("Warning: Certificate format is currently not validated.") + + def test_init_timestamp_defaults_to_current_utc(self): + """Verify timestamp defaults to current UTC when not provided.""" + + # Act + topic_list = TopicList( + owner="github", + name="github", + ) + + # Assert + assert topic_list.timestamp is not None + assert isinstance(topic_list.timestamp, datetime) + # Should have UTC timezone info + assert topic_list.timestamp.tzinfo is not None + + def test_version_default(self): + """Verify version defaults to None.""" + + # Act + topic_list = TopicList( + owner="github", + name="github", + ) + + # Assert + assert topic_list.version is None + # Methods def test_add_topic_string(self): """Add a new topic using a string ID.""" @@ -137,6 +220,95 @@ def test_full_name(self): # Assert assert full_name == "github/git" + def test_version_setter_valid_format(self): + """Test setting version with valid semantic versioning.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github", + ) + + # Act + topic_list.version = "3.2.1" + + # Assert + assert topic_list.version == "3.2.1" + + def test_version_setter_invalid_format_missing_patch(self): + """Test setting version with invalid format (missing patch number).""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github", + ) + + # Act & Assert + try: + topic_list.version = "3.2" + assert False, "Should have raised ValueError" + except ValueError as e: + assert "Invalid version format" in str(e) + assert "Must be semantic versioning" in str(e) + + def test_version_setter_invalid_format_extra_component(self): + """Test setting version with invalid format (too many components).""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github", + ) + + # Act + result = None + try: + topic_list.version = "3.2.1.5" + assert False, "Should have raised ValueError" + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "Invalid" in str(result) + + def test_version_setter_invalid_format_non_numeric(self): + """Test setting version with invalid format (non-numeric components).""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github", + ) + + # Act + result = None + try: + topic_list.version = "v3.2.1" + assert False, "Should have raised ValueError" + except Exception as e: + result = e + + # Assert + assert isinstance(result, ValueError) + assert "Invalid" in str(result) + + def test_version_zero_values(self): + """Test setting version with zero values.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github", + ) + + # Act + topic_list.version = "0.0.0" + + # Assert + assert topic_list.version == "0.0.0" + # Methods - Class def test_load_from_json_basic_info(self): """Load a list with only list info.""" @@ -145,8 +317,7 @@ def test_load_from_json_basic_info(self): json_data = """ { "owner": "github", - "name": "github-features", - "description": "Features of the GitHub platform" + "name": "github-features" } """ @@ -156,7 +327,29 @@ def test_load_from_json_basic_info(self): # Assert - list details assert topic_list.owner == "github" assert topic_list.name == "github-features" - assert topic_list.description == "Features of the GitHub platform" + + def test_load_from_json_optional_inputs(self): + """Load a list with version, timestamp, and certificate fields.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform", + "version": "2.3.1", + "timestamp": "2025-01-15T10:30:00+00:00", + "certificate": "cert-abc123" + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert + assert topic_list.version == "2.3.1" + assert topic_list.timestamp == datetime.fromisoformat("2025-01-15T10:30:00+00:00") + assert topic_list.certificate == "cert-abc123" def test_load_from_json_simple(self): """Load a list with only top-level topics.""" @@ -420,6 +613,69 @@ def test_to_dict_simple(self): assert "git-clone" in data["topics"]["repositories"]["subtopics"] assert "git-push" in data["topics"]["repositories"]["pretopics"] + def test_to_dict(self): + """Test that to_dict excludes optional fields if set to None.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github-features", + ) + + # Act + data = topic_list.to_dict() + + # Assert + assert "description" not in data + assert "version" not in data + assert "certificate" not in data + + def test_to_dict_with_optionals(self): + """Test that to_dict includes optional fields.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github-features", + description="Features of the GitHub platform", + version="2.1.0", + timestamp="2025-01-15T10:30:00+00:00", + certificate="cert-xyz789", + ) + + # Act + data = topic_list.to_dict() + + # Assert + assert data["version"] == "2.1.0" + assert data["timestamp"] == "2025-01-15T10:30:00+00:00" + assert data["certificate"] == "cert-xyz789" + + def test_json_roundtrip_integrity(self): + """Test that JSON export/import preserves values.""" + + # Arrange + original = TopicList( + owner="github", + name="github-features", + description="Test description", + version="1.2.3", + timestamp="2025-02-16T14:25:00+00:00", + certificate="cert-example", + ) + + # Act + json_str = original.to_json() + restored = TopicList.from_json(json_str) + + # Assert + assert restored.owner == original.owner + assert restored.name == original.name + assert restored.description == original.description + assert restored.version == original.version + assert restored.timestamp == original.timestamp + assert restored.certificate == original.certificate + def test_to_json_simple(self): """Exporting a simple TopicList to JSON string."""