Skip to content

Commit bd942ae

Browse files
committed
feat: add from_dict and from_json for ProficiencyLevelList
1 parent 6983503 commit bd942ae

2 files changed

Lines changed: 334 additions & 3 deletions

File tree

openproficiency/ProficiencyLevelList.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import re
55
from datetime import datetime, timezone
6-
from typing import Optional, Dict, Any, Union, List
6+
from typing import Optional, Dict, Any, Union, List, cast
77
from .ProficiencyLevel import ProficiencyLevel
88
from .TopicList import TopicList
99
from .validators import validate_kebab_case, validate_hostname
@@ -99,7 +99,7 @@ def full_name(self) -> str:
9999
return full_name
100100

101101
# Methods
102-
def add_level(self, level: ProficiencyLevel) -> None:
102+
def add_level(self, level: ProficiencyLevel, validate: bool = True) -> None:
103103
"""
104104
Add a proficiency level to this list.
105105
Validates that all pretopics reference valid topics in dependencies.
@@ -109,7 +109,8 @@ def add_level(self, level: ProficiencyLevel) -> None:
109109
raise ValueError(f"A proficiency level with ID '{level.id}' already exists in this list")
110110

111111
# Validate pretopics
112-
self._validate_pretopics(level)
112+
if validate:
113+
self._validate_pretopics(level)
113114

114115
# Add the level
115116
self.levels[level.id] = level
@@ -201,6 +202,70 @@ def to_json(self) -> str:
201202
"""Convert ProficiencyLevelList to JSON string."""
202203
return json.dumps(self.to_dict())
203204

205+
# Methods - Class
206+
@staticmethod
207+
def from_dict(data: Dict[str, Any]) -> "ProficiencyLevelList":
208+
"""
209+
Create a ProficiencyLevelList from a dictionary.
210+
Optionally provide TopicList objects for dependencies.
211+
"""
212+
# Create empty ProficiencyLevelList
213+
level_list = ProficiencyLevelList(
214+
owner=data["owner"],
215+
name=data["name"],
216+
description=data.get("description", None),
217+
version=data["version"],
218+
timestamp=data["timestamp"],
219+
certificate=data["certificate"],
220+
)
221+
222+
# Add dependencies if provided
223+
dependencies = cast(Dict[str, str], data.get("dependencies", {}))
224+
for namespace, topic_list_full_name in dependencies.items():
225+
# Extract from full name like 'example.com/math-topics@1.0.0'
226+
owner = topic_list_full_name.split("/")[0]
227+
name = topic_list_full_name.split("/")[1].split("@")[0]
228+
version = topic_list_full_name.split("@")[1]
229+
# Load topic list
230+
# TODO: In the future this should use the url to retrieve the list
231+
# and get metadata from the list's json.
232+
topic_list = TopicList(
233+
owner=owner,
234+
name=name,
235+
version=version,
236+
)
237+
# Assign to namespace
238+
level_list.dependencies[namespace] = topic_list
239+
240+
# Add each level
241+
levels = cast(Dict[str, Any], data.get("proficiency-levels", {}))
242+
for level_id, level_data in levels.items():
243+
if isinstance(level_data, dict):
244+
level_dict = cast(Dict[str, Any], level_data)
245+
level = ProficiencyLevel(
246+
id=level_id,
247+
description=level_dict.get("description"),
248+
pretopics=set(level_dict.get("pretopics", [])),
249+
)
250+
level_list.add_level(level, validate=False)
251+
252+
return level_list
253+
254+
@staticmethod
255+
def from_json(json_data: str) -> "ProficiencyLevelList":
256+
"""
257+
Load a ProficiencyLevelList from JSON string.
258+
Optionally provide TopicList objects for dependencies.
259+
"""
260+
# Verify input is json string
261+
try:
262+
data = json.loads(json_data)
263+
except TypeError:
264+
raise TypeError("Unable to import. 'json_data' must be a JSON string")
265+
except Exception as e:
266+
raise e
267+
268+
return ProficiencyLevelList.from_dict(data)
204269

205270
# Debugging
206271
def __repr__(self) -> str:

tests/ProficiencyLevelList_test.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,272 @@ def test_to_json(self):
703703
assert data["name"] == "levels"
704704
assert "beginner" in data["proficiency-levels"]
705705

706+
# Methods - from_dict
707+
def test_from_dict_simple(self):
708+
"""Test creating from dictionary with minimal details."""
709+
710+
# Arrange
711+
data: Dict[str, Any] = {
712+
"owner": "example.com",
713+
"name": "levels",
714+
"description": "Test levels",
715+
"version": "1.0.0",
716+
"timestamp": "2025-01-15T10:30:00+00:00",
717+
"certificate": "--- cert ---",
718+
}
719+
720+
# Act
721+
level_list = ProficiencyLevelList.from_dict(data)
722+
723+
# Assert
724+
assert level_list.owner == "example.com"
725+
assert level_list.name == "levels"
726+
assert level_list.description == "Test levels"
727+
assert level_list.version == "1.0.0"
728+
assert level_list.timestamp.isoformat() == "2025-01-15T10:30:00+00:00"
729+
assert level_list.certificate == "--- cert ---"
730+
731+
def test_from_dict_levels_dependencies(self):
732+
"""Test creating from dictionary with levels and dependencies."""
733+
734+
# Arrange
735+
data: Dict[str, Any] = {
736+
"owner": "example.com",
737+
"name": "levels",
738+
"version": "1.0.0",
739+
"timestamp": "2025-01-15T10:30:00+00:00",
740+
"certificate": "cert",
741+
"proficiency-levels": {
742+
"beginner": {
743+
"description": "Beginner level",
744+
"pretopics": [],
745+
},
746+
"advanced": {
747+
"description": "Advanced level",
748+
"pretopics": [],
749+
},
750+
},
751+
"dependencies": {
752+
"math": "example.com/math-topics@1.0.0",
753+
},
754+
}
755+
756+
# Act
757+
level_list = ProficiencyLevelList.from_dict(data)
758+
759+
# Assert
760+
assert len(level_list.levels) == 2
761+
assert "beginner" in level_list.levels
762+
assert "advanced" in level_list.levels
763+
assert level_list.levels["beginner"].description == "Beginner level"
764+
765+
assert "math" in level_list.dependencies
766+
assert level_list.dependencies["math"].owner == "example.com"
767+
assert level_list.dependencies["math"].name == "math-topics"
768+
assert level_list.dependencies["math"].version == "1.0.0"
769+
770+
# Methods - from_json
771+
def test_from_json_basic(self):
772+
"""Test creating from JSON string."""
773+
774+
# Arrange
775+
json_str = json.dumps(
776+
{
777+
"owner": "example.com",
778+
"name": "levels",
779+
"description": "Test levels",
780+
"version": "1.0.0",
781+
"timestamp": "2025-01-15T10:30:00+00:00",
782+
"certificate": "--- cert ---",
783+
}
784+
)
785+
786+
# Act
787+
level_list = ProficiencyLevelList.from_json(json_str)
788+
789+
# Assert
790+
assert level_list.owner == "example.com"
791+
assert level_list.name == "levels"
792+
assert level_list.description == "Test levels"
793+
assert level_list.version == "1.0.0"
794+
assert level_list.timestamp.isoformat() == "2025-01-15T10:30:00+00:00"
795+
assert level_list.certificate == "--- cert ---"
796+
797+
def test_from_json_with_levels(self):
798+
"""Test creating from JSON string with levels and dependencies."""
799+
800+
# Arrange
801+
json_str = json.dumps(
802+
{
803+
"owner": "example.com",
804+
"name": "levels",
805+
"version": "1.0.0",
806+
"timestamp": "2025-01-15T10:30:00+00:00",
807+
"certificate": "cert",
808+
"proficiency-levels": {
809+
"beginner": {
810+
"description": "Beginner level",
811+
"pretopics": [],
812+
},
813+
"advanced": {
814+
"description": "Advanced level",
815+
"pretopics": [],
816+
},
817+
},
818+
"dependencies": {
819+
"math": "example.com/math-topics@1.0.0",
820+
},
821+
}
822+
)
823+
824+
# Act
825+
level_list = ProficiencyLevelList.from_json(json_str)
826+
827+
# Assert
828+
assert len(level_list.levels) == 2
829+
assert "beginner" in level_list.levels
830+
assert "advanced" in level_list.levels
831+
assert level_list.levels["beginner"].description == "Beginner level"
832+
833+
assert "math" in level_list.dependencies
834+
assert level_list.dependencies["math"].owner == "example.com"
835+
assert level_list.dependencies["math"].name == "math-topics"
836+
assert level_list.dependencies["math"].version == "1.0.0"
837+
838+
def test_from_json_invalid_input(self):
839+
"""Test that non-JSON input raises error."""
840+
841+
# Arrange
842+
json_str = "not a json string"
843+
844+
# Act
845+
result = None
846+
try:
847+
ProficiencyLevelList.from_json(json_data=json_str)
848+
except Exception as e:
849+
result = e
850+
851+
# Assert
852+
assert isinstance(result, json.JSONDecodeError)
853+
854+
# Methods - Round trip (to_dict -> from_dict)
855+
def test_round_trip_to_dict_from_dict(self):
856+
"""Test converting to dict and back preserves data."""
857+
858+
# Arrange
859+
topic_list = TopicList(
860+
owner="example.com",
861+
name="math-topics",
862+
version="1.0.0",
863+
timestamp="2025-01-15T10:30:00+00:00",
864+
certificate="cert",
865+
)
866+
topic_list.add_topic(Topic(id="addition"))
867+
topic_list.add_topic(Topic(id="subtraction"))
868+
869+
level_list = ProficiencyLevelList(
870+
owner="example.com",
871+
name="levels",
872+
description="Math proficiency levels",
873+
version="1.0.0",
874+
timestamp="2025-01-15T10:30:00+00:00",
875+
certificate="https://example.com/cert.pem",
876+
levels={
877+
"beginner": ProficiencyLevel(
878+
id="beginner",
879+
description="Beginner level",
880+
pretopics={
881+
"math.addition",
882+
},
883+
),
884+
"intermediate": ProficiencyLevel(
885+
id="intermediate",
886+
description="Intermediate level",
887+
pretopics={
888+
"math.subtraction",
889+
},
890+
),
891+
},
892+
dependencies={
893+
"math": topic_list,
894+
},
895+
)
896+
897+
# Act
898+
data = level_list.to_dict()
899+
result = ProficiencyLevelList.from_dict(data)
900+
901+
# Assert
902+
assert result.owner == level_list.owner
903+
assert result.name == level_list.name
904+
assert result.description == level_list.description
905+
assert result.version == level_list.version
906+
assert result.timestamp == level_list.timestamp
907+
assert result.certificate == level_list.certificate
908+
909+
assert len(result.dependencies) == 1
910+
assert "math" in result.dependencies
911+
assert result.dependencies["math"].full_name == topic_list.full_name
912+
913+
assert len(result.levels) == 2
914+
assert result.levels["beginner"].description == "Beginner level"
915+
assert "math.addition" in result.levels["beginner"].pretopics
916+
assert result.levels["intermediate"].description == "Intermediate level"
917+
assert "math.subtraction" in result.levels["intermediate"].pretopics
918+
919+
def test_round_trip_to_json_from_json(self):
920+
"""Test converting to JSON and back preserves data."""
921+
922+
# Arrange
923+
topic_list = TopicList(
924+
owner="example.com",
925+
name="math-topics",
926+
version="1.0.0",
927+
timestamp="2025-01-15T10:30:00+00:00",
928+
certificate="cert",
929+
)
930+
topic_list.add_topic(Topic(id="addition"))
931+
topic_list.add_topic(Topic(id="subtraction"))
932+
933+
level_list = ProficiencyLevelList(
934+
owner="example.com",
935+
name="levels",
936+
description="Math proficiency levels",
937+
version="1.0.0",
938+
timestamp="2025-01-15T10:30:00+00:00",
939+
certificate="https://example.com/cert.pem",
940+
dependencies={
941+
"math": topic_list,
942+
},
943+
)
944+
level_list.add_level(
945+
ProficiencyLevel(
946+
id="beginner",
947+
description="Beginner level",
948+
pretopics={
949+
"math.addition",
950+
},
951+
)
952+
)
953+
level_list.add_level(
954+
ProficiencyLevel(
955+
id="intermediate",
956+
description="Intermediate level",
957+
pretopics={
958+
"math.subtraction",
959+
},
960+
)
961+
)
962+
963+
# Act
964+
json_str = level_list.to_json()
965+
result = ProficiencyLevelList.from_json(json_str)
966+
round_trip_json = result.to_json()
967+
968+
# Assert
969+
data = json.loads(json_str)
970+
round_trip_data = json.loads(round_trip_json)
971+
assert round_trip_data == data
706972

707973
# Debugging
708974
def test_repr(self):

0 commit comments

Comments
 (0)