Skip to content

Commit bd887d8

Browse files
authored
feat: add classes for of ProficiencyLevel and ProficiencyLevelList (#7)
1 parent 5d0f43e commit bd887d8

File tree

5 files changed

+1819
-0
lines changed

5 files changed

+1819
-0
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""ProficiencyLevel module for OpenProficiency library."""
2+
3+
import json
4+
from typing import Any, Dict, Optional, Union, Set
5+
from .validators import validate_kebab_case
6+
7+
8+
class ProficiencyLevel:
9+
"""Class representing a proficiency level defined by prerequisite topics."""
10+
11+
# Initializers
12+
def __init__(
13+
self,
14+
# Required
15+
id: str,
16+
# Optional
17+
description: Optional[str] = None,
18+
pretopics: Optional[Set[str]] = None,
19+
):
20+
# Required
21+
self.id = id
22+
# Optional
23+
self.description = description
24+
if pretopics is None:
25+
pretopics = set()
26+
self.pretopics = pretopics
27+
28+
# Properties
29+
@property
30+
def id(self) -> str:
31+
"""Get the proficiency level ID."""
32+
return self._id
33+
34+
@id.setter
35+
def id(self, value: str) -> None:
36+
"""Set the proficiency level ID. kebab-case"""
37+
validate_kebab_case(value)
38+
self._id = value
39+
40+
@property
41+
def description(self) -> Optional[str]:
42+
"""Get the description."""
43+
return self._description
44+
45+
@description.setter
46+
def description(self, value: Optional[Union[str, None]]) -> None:
47+
"""Set the description. Max 100 characters."""
48+
if value is not None and len(value) > 100:
49+
raise ValueError(f"Description must be 100 characters or less. Got {len(value)} characters.")
50+
self._description = value
51+
52+
# Methods
53+
def add_pretopic(self, pretopic: str) -> None:
54+
"""
55+
Add a pretopic (prerequisite topic) to this proficiency level.
56+
"""
57+
self.pretopics.add(pretopic)
58+
59+
def add_pretopics(self, pretopics: Set[str]) -> None:
60+
"""
61+
Add multiple pretopics to this proficiency level.
62+
"""
63+
self.pretopics.update(pretopics)
64+
65+
def remove_pretopic(self, pretopic: str) -> None:
66+
"""Remove a pretopic by its ID."""
67+
self.pretopics.discard(pretopic)
68+
69+
def to_dict(self) -> Dict[str, Any]:
70+
"""Convert ProficiencyLevel to JSON-serializable dictionary."""
71+
return {
72+
"id": self.id,
73+
"description": self.description,
74+
"pretopics": list(self.pretopics),
75+
}
76+
77+
def to_json(self) -> str:
78+
"""Convert ProficiencyLevel to JSON string."""
79+
return json.dumps(self.to_dict())
80+
81+
# Methods - Static
82+
@staticmethod
83+
def from_dict(data: Dict[str, Any]) -> "ProficiencyLevel":
84+
"""Create a ProficiencyLevel instance from a dictionary."""
85+
return ProficiencyLevel(
86+
id=data["id"],
87+
description=data.get("description", ""),
88+
pretopics=set(data.get("pretopics", [])),
89+
)
90+
91+
@staticmethod
92+
def from_json(json_str: str) -> "ProficiencyLevel":
93+
"""Create a ProficiencyLevel instance from a JSON string."""
94+
try:
95+
data = json.loads(json_str)
96+
except json.JSONDecodeError as e:
97+
raise ValueError(f"Invalid JSON: {e}")
98+
return ProficiencyLevel.from_dict(data)
99+
100+
def __eq__(self, other: Any) -> bool:
101+
"""Check equality based and pretopics."""
102+
if not isinstance(other, ProficiencyLevel):
103+
return False
104+
return set(self.pretopics) == set(other.pretopics)
105+
106+
# Debugging
107+
def __repr__(self) -> str:
108+
"""String representation of ProficiencyLevel."""
109+
return f"ProficiencyLevel(id='{self.id}', " f"description='{self.description}', " f"pretopics={self.pretopics})"
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""ProficiencyLevelList module for OpenProficiency library."""
2+
3+
import json
4+
import re
5+
from datetime import datetime, timezone
6+
from typing import Optional, Dict, Any, Union, List, cast
7+
from .ProficiencyLevel import ProficiencyLevel
8+
from .TopicList import TopicList
9+
from .validators import validate_kebab_case, validate_hostname
10+
11+
12+
class ProficiencyLevelList:
13+
"""Class representing a collection of proficiency levels with dependencies."""
14+
15+
# Initializers
16+
def __init__(
17+
self,
18+
# Required
19+
owner: str,
20+
name: str,
21+
version: str,
22+
timestamp: Union[str, datetime],
23+
certificate: str,
24+
# Optional
25+
description: Optional[str] = None,
26+
levels: Optional[Dict[str, ProficiencyLevel]] = None,
27+
dependencies: Optional[Dict[str, TopicList]] = None,
28+
):
29+
# Required
30+
self.owner = owner
31+
self.name = name
32+
self.version = version
33+
self.timestamp = timestamp
34+
self.certificate = certificate
35+
36+
# Optional
37+
self.description = description
38+
self.levels = levels or {}
39+
self.dependencies = dependencies or {}
40+
41+
# Properties
42+
@property
43+
def owner(self) -> str:
44+
"""Get the owner name."""
45+
return self._owner
46+
47+
@owner.setter
48+
def owner(self, value: str) -> None:
49+
"""Set the owner with hostname validation. Format: hostname, Ex: `example.com`"""
50+
validate_hostname(value)
51+
self._owner = value
52+
53+
@property
54+
def name(self) -> str:
55+
"""Get the ProficiencyLevelList name. Format: kebab-case"""
56+
return self._name
57+
58+
@name.setter
59+
def name(self, value: str) -> None:
60+
"""Set the ProficiencyLevelList name with kebab-case validation."""
61+
validate_kebab_case(value)
62+
self._name = value
63+
64+
@property
65+
def version(self) -> Union[str, None]:
66+
"""Get the semantic version of the ProficiencyLevelList."""
67+
return self._version
68+
69+
@version.setter
70+
def version(self, value: Union[str, None]) -> None:
71+
"""Set the semantic version with X.Y.Z format validation."""
72+
if value is not None and not re.match(r"^\d+\.\d+\.\d+$", value):
73+
raise ValueError(f"Invalid version format: '{value}'. Must be semantic versioning (X.Y.Z)")
74+
self._version = value
75+
76+
@property
77+
def timestamp(self) -> datetime:
78+
"""Get the timestamp as a datetime object."""
79+
return self._timestamp
80+
81+
@timestamp.setter
82+
def timestamp(self, value: Union[datetime, str, None]) -> None:
83+
"""Set the timestamp from a string or datetime object."""
84+
if value is None:
85+
self._timestamp = datetime.now(timezone.utc)
86+
elif isinstance(value, datetime):
87+
self._timestamp = value
88+
elif isinstance(value, str):
89+
self._timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
90+
else:
91+
raise ValueError("Invalid timestamp format. Must be a datetime object or ISO 8601 string.")
92+
93+
@property
94+
def full_name(self) -> str:
95+
"""Get the full name of the ProficiencyLevelList in 'owner/name@version' format."""
96+
full_name = f"{self.owner}/{self.name}"
97+
if self.version:
98+
full_name += f"@{self.version}"
99+
return full_name
100+
101+
# Methods
102+
def add_level(self, level: ProficiencyLevel, validate: bool = True) -> None:
103+
"""
104+
Add a proficiency level to this list.
105+
Validates that all pretopics reference valid topics in dependencies.
106+
"""
107+
# Check for duplicate ID
108+
if level.id in self.levels:
109+
raise ValueError(f"A proficiency level with ID '{level.id}' already exists in this list")
110+
111+
# Validate pretopics
112+
if validate:
113+
self._validate_pretopics(level)
114+
115+
# Add the level
116+
self.levels[level.id] = level
117+
118+
def add_dependency(self, namespace: str, topic_list: TopicList) -> None:
119+
"""
120+
Add an imported TopicList as a dependency.
121+
"""
122+
# Validate namespace format (kebab-case)
123+
validate_kebab_case(namespace)
124+
125+
# Check for duplicate namespace
126+
if namespace in self.dependencies:
127+
raise ValueError(f"A dependency with namespace '{namespace}' already exists in this list")
128+
129+
# Add the dependency
130+
self.dependencies[namespace] = topic_list
131+
132+
def _validate_pretopics(self, level: ProficiencyLevel) -> None:
133+
"""
134+
Validate that all pretopics in a level reference valid topics in dependencies.
135+
Pretopics must be in format 'namespace.topic-id'.
136+
"""
137+
errors: List[str] = []
138+
139+
for pretopic in level.pretopics:
140+
# Parse namespace and topic ID
141+
if "." not in pretopic:
142+
errors.append(
143+
f"Pretopic '{pretopic}' in level '{level.id}' is not in "
144+
"namespace notation format (expected 'namespace.topic-id')"
145+
)
146+
continue
147+
148+
parts = pretopic.split(".", 1)
149+
namespace = parts[0]
150+
topic_id = parts[1]
151+
152+
# Check if namespace exists in dependencies
153+
if namespace not in self.dependencies:
154+
errors.append(
155+
f"Pretopic '{pretopic}' in level '{level.id}' references unknown " f"namespace '{namespace}'"
156+
)
157+
continue
158+
159+
# Check if topic exists in the TopicList
160+
topic_list = self.dependencies[namespace]
161+
if topic_list.get_topic(topic_id) is None:
162+
errors.append(
163+
f"Pretopic '{pretopic}' in level '{level.id}' references "
164+
f"non-existent topic '{topic_id}' in namespace '{namespace}'"
165+
)
166+
167+
# If there are any errors, raise them all together
168+
if errors:
169+
error_message = "; ".join(errors)
170+
raise ValueError(error_message)
171+
172+
def to_dict(self) -> Dict[str, Any]:
173+
"""
174+
Export the ProficiencyLevelList to a dictionary.
175+
"""
176+
# Create dictionary
177+
data: Dict[str, Any] = {
178+
"owner": self.owner,
179+
"name": self.name,
180+
"version": self.version,
181+
"timestamp": self.timestamp.isoformat(),
182+
"certificate": self.certificate,
183+
"proficiency-levels": {},
184+
"dependencies": {},
185+
}
186+
187+
# Add description if set
188+
if self.description is not None:
189+
data["description"] = self.description
190+
191+
# Add dependencies
192+
for namespace, topic_list in self.dependencies.items():
193+
data["dependencies"][namespace] = topic_list.full_name
194+
195+
# Add each level
196+
for level_id, level in self.levels.items():
197+
data["proficiency-levels"][level_id] = level.to_dict()
198+
199+
return data
200+
201+
def to_json(self) -> str:
202+
"""Convert ProficiencyLevelList to JSON string."""
203+
return json.dumps(self.to_dict())
204+
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)
269+
270+
# Debugging
271+
def __repr__(self) -> str:
272+
"""String representation of ProficiencyLevelList."""
273+
return (
274+
f"ProficiencyLevelList(owner='{self.owner}', name='{self.name}', "
275+
f"levels_count={len(self.levels)}, dependencies_count={len(self.dependencies)})"
276+
)

openproficiency/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
from .TopicList import TopicList
55
from .ProficiencyScore import ProficiencyScore, ProficiencyScoreName
66
from .TranscriptEntry import TranscriptEntry
7+
from .ProficiencyLevel import ProficiencyLevel
8+
from .ProficiencyLevelList import ProficiencyLevelList

0 commit comments

Comments
 (0)