From 5263ff5f744ffb819bb3709c79f86c6d4ac2f100 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 16:45:35 +0000 Subject: [PATCH 01/14] feat: add initial README and .gitignore --- .gitignore | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 +- 2 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1019e1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,302 @@ +### +### Python - https://github.com/github/gitignore/blob/main/Python.gitignore +### + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + + +### +### Linux - https://github.com/github/gitignore/blob/main/Global/Linux.gitignore +### + +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# Metadata left by Dolphin file manager, which comes with KDE Plasma +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# Log files created by default by the nohup command +nohup.out + +### +### macOS - https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +### + +# General +.DS_Store +__MACOSX/ +.AppleDouble +.LSOverride +Icon[ +] + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### +### Windows - https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +### + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk \ No newline at end of file diff --git a/README.md b/README.md index be2ce23..b5fd3d0 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ -# python-sdk \ No newline at end of file +# OpenProficiency - Python Library + +A lightweight Python library for managing proficiency topics and topic lists. This library provides simple class objects for working with proficiency domains, making it easy to build applications that track and manage knowledge areas. + From 934dba4aa8ec361c9018bc43ed998341eebdcb40 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 17:03:01 +0000 Subject: [PATCH 02/14] feat: add Topic class --- openproficiency/Topic.py | 59 +++++++++++++++++++++ tests/Topic_test.py | 112 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 openproficiency/Topic.py create mode 100644 tests/Topic_test.py diff --git a/openproficiency/Topic.py b/openproficiency/Topic.py new file mode 100644 index 0000000..bb5e228 --- /dev/null +++ b/openproficiency/Topic.py @@ -0,0 +1,59 @@ +"""Topic module for OpenProficiency library.""" + +from typing import List, Union + + +class Topic: + + # Initializers + def __init__( + self, + # Required + id: str, + # Optional + description: str = "", + ): + # Required + self.id = id + + # Optional + self.description = description + self.subtopics: List[str] = [] + self.pretopics: List[str] = [] + + # Methods + def add_subtopic(self, subtopic: Union[str, "Topic"]) -> None: + """ + Add a subtopic to this topic. + Supports a string ID or another Topic instance. + """ + # Support direct string + if isinstance(subtopic, str): + self.subtopics.append(subtopic) + + # Support Topic object + elif isinstance(subtopic, Topic): + self.subtopics.append(subtopic.id) + + else: + raise ValueError("Subtopic must be a string or a dictionary with an 'id' key.") + + def add_pretopic(self, pretopic: Union[str, "Topic"]) -> None: + """ + Add a pretopic to this topic. + Supports a string ID or a dictionary with an 'id' key. + """ + # Support direct string + if isinstance(pretopic, str): + self.pretopics.append(pretopic) + + # Support Topic object + elif isinstance(pretopic, Topic): + self.pretopics.append(pretopic.id) + else: + raise ValueError("Pretopic must be a string or a dictionary with an 'id' key.") + + # Debugging + def __repr__(self) -> str: + """String representation of Topic.""" + return f"Topic(id='{self.id}', description='{self.description}')" diff --git a/tests/Topic_test.py b/tests/Topic_test.py new file mode 100644 index 0000000..bf76d6a --- /dev/null +++ b/tests/Topic_test.py @@ -0,0 +1,112 @@ +from openproficiency import Topic + + +class TestTopic: + + # Initializers + def test_init_required_params(self): + """Create a topic with requied fields only""" + + # Arrange + id = "git-commit" + + # Act + topic = Topic(id=id) + + # Assert + assert topic.id == id + + # Assert - default values + assert topic.description == "" + assert topic.subtopics == [] + assert topic.pretopics == [] + + def test_init_optional_params(self): + """Test creating a topic with subtopics.""" + + # Arrange + id = "git-commit" + description = "Saving changes to the Git history" + + # Act + topic = Topic( + id=id, + description=description + ) + + # Assert + assert topic.description == description + + # Methods + def test_add_subtopic_string(self): + """Test adding a subtopic as a string.""" + + # Arrange + topic = Topic(id="git") + subtopic_id = "git-commit" + + # Act + topic.add_subtopic(subtopic_id) + + # Assert + assert subtopic_id in topic.subtopics + + def test_add_subtopic_topic(self): + """Test adding a subtopic as a Topic instance.""" + + # Arrange + topic = Topic(id="git") + subtopic = Topic( + id="git-commit", + description="Saving changes to the Git history" + ) + + # Act + topic.add_subtopic(subtopic) + + # Assert + assert subtopic.id in topic.subtopics + + def test_add_pretopic_string(self): + """Test adding a pretopic as a string.""" + + # Arrange + topic = Topic(id="git") + pretopic_id = "version-control" + + # Act + topic.add_pretopic(pretopic_id) + + # Assert + assert pretopic_id in topic.pretopics + + def test_add_pretopic_topic(self): + """Test adding a pretopic as a Topic instance.""" + + # Arrange + topic = Topic(id="git") + pretopic = Topic( + id="version-control", + description="Managing changes to code over time" + ) + + # Act + topic.add_pretopic(pretopic) + + # Assert + assert pretopic.id in topic.pretopics + + # Debugging + def test_topic_repr(self): + """Check string representation of a Topic""" + + # Arrange + topic = Topic(id="git", description="Git version control") + + # Act + repr_str = repr(topic) + + # Assert + assert "git" in repr_str + assert "Git version control" in repr_str + assert "Topic" in repr_str From 435741531963a89cfb67fce7dfcc8f61c630747c Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 17:08:58 +0000 Subject: [PATCH 03/14] feat: add TopicList class --- openproficiency/TopicList.py | 237 ++++++++++++++++++++ tests/TopicList_test.py | 416 +++++++++++++++++++++++++++++++++++ 2 files changed, 653 insertions(+) create mode 100644 openproficiency/TopicList.py create mode 100644 tests/TopicList_test.py diff --git a/openproficiency/TopicList.py b/openproficiency/TopicList.py new file mode 100644 index 0000000..f6c86a7 --- /dev/null +++ b/openproficiency/TopicList.py @@ -0,0 +1,237 @@ +"""TopicList module for OpenProficiency library.""" + +import json +from datetime import datetime +from typing import Optional, Dict, Any, Union +from .Topic import Topic + + +class TopicList: + + # Initializers + def __init__( + self, + # Required + owner: str, + name: str, + # Optional + description: str = "", + ): + # Required + self.owner = owner + self.name = name + + # Optional + self.description = description + self.topics: Dict[str, Topic] = {} + self.dependencies: Dict[str, "TopicList"] = {} + + # Methods + def add_topic(self, topic: Union[str, Topic]) -> Topic: + """ + Add a topic to this list. + Supports a string ID or a Topic instance. + """ + + # Support string ID + if isinstance(topic, str): + topic = Topic(id=topic) + + # Add Topic + self.topics[topic.id] = topic + return topic + + def get_topic(self, topic_id: str) -> Union[Topic, None]: + return self.topics.get(topic_id, None) + + # Properties + @property + def full_name(self) -> str: + """Get the full name of the TopicList in 'owner/name' format.""" + return f"{self.owner}/{self.name}" + + # Debugging + def __repr__(self) -> str: + """String representation of TopicList.""" + return f"TopicList(owner='{self.owner}', name='{self.name}', topics_count={len(self.topics)})" + + # Methods - Class + @classmethod + def from_json(cls, json_data: str) -> "TopicList": + """ + Load a TopicList from JSON document. + If a topic is defined multiple times, only the last definition stays. + """ + + # Verify input is json string + try: + data = json.loads(json_data) + except TypeError: + raise TypeError("Unable to import. 'json_data' must be a JSON string") + except Exception as e: + raise e + + # Create empty TopicList + topic_list = TopicList( + owner=data.get("owner", ""), + name=data.get("name", ""), + description=data.get("description", ""), + ) + + # Add each topic + topics = data.get("topics", {}) + for topic_id, topic_data in topics.items(): + + # Find or create Topic + topic = topic_list.get_topic(topic_id) + if topic is None: + topic = topic_list.add_topic(Topic(id=topic_id)) + + if isinstance(topic_data, dict): + topic.description = topic_data.get("description", "") + + # Add subtopics + cls._add_subtopics_recursive( + topic_list=topic_list, + parent_topic=topic, + subtopics=topic_data.get("subtopics", []), + ) + + # Add pretopics + cls._add_pretopics_recursive( + topic_list=topic_list, + child_topic=topic, + pretopics=topic_data.get("pretopics", []), + ) + + else: + print("Unknown topic data format: ${topic_id}='${topic_data}'") + + return topic_list + + @staticmethod + def _add_subtopics_recursive( + topic_list: "TopicList", + parent_topic: Topic, + subtopics: list, + ) -> None: + """ + Process subtopics and add them to the topic list. + Handles nested subtopics at any depth using an iterative approach. + """ + stack = [(subtopics, parent_topic)] + + while stack: + current_subtopics, current_parent = stack.pop() + + for subtopic_object in current_subtopics: + subtopic = None + + # Handle string ID + if isinstance(subtopic_object, str): + # Check if the topic already exists + subtopic = topic_list.get_topic(subtopic_object) + if subtopic is None: + subtopic = Topic(id=subtopic_object) + + # Handle dictionary with id and optional nested subtopics + elif isinstance(subtopic_object, dict) and "id" in subtopic_object: + # Check if the topic already exists + subtopic = topic_list.get_topic(subtopic_object["id"]) + if subtopic is None: + subtopic = Topic( + id=subtopic_object["id"], + description=subtopic_object.get("description", ""), + ) + + # Queue nested subtopics for processing + nested_subtopics = subtopic_object.get("subtopics", []) + if nested_subtopics: + stack.append((nested_subtopics, subtopic)) + + # Add subtopic to topic list and parent topic + if subtopic is not None: + topic_list.add_topic(subtopic) + current_parent.add_subtopic(subtopic) + + @staticmethod + def _add_pretopics_recursive( + topic_list: "TopicList", + child_topic: Topic, + pretopics: list, + ) -> None: + """ + Process pretopics and add them to the topic list. + Handles nested pretopics at any depth using an iterative approach. + Pretopics inherit description from child topic if not explicitly set. + """ + stack = [(pretopics, child_topic)] + + while stack: + current_pretopics, current_child = stack.pop() + + for pretopic_object in current_pretopics: + pretopic = None + + # Handle string ID - inherit description from child topic + if isinstance(pretopic_object, str): + # Check if the topic already exists + pretopic = topic_list.get_topic(pretopic_object) + if pretopic is None: + pretopic = Topic( + id=pretopic_object, description=current_child.description + ) + + # Handle dictionary with id and optional nested pretopics + elif isinstance(pretopic_object, dict) and "id" in pretopic_object: + # Check if the topic already exists + pretopic = topic_list.get_topic(pretopic_object["id"]) + if pretopic is None: + pretopic = Topic( + id=pretopic_object["id"], + description=pretopic_object.get( + "description", current_child.description + ), + ) + + # Queue nested pretopics for processing + nested_pretopics = pretopic_object.get("pretopics", []) + if nested_pretopics: + stack.append((nested_pretopics, pretopic)) + + # Add pretopic to topic list and child topic + if pretopic is not None: + topic_list.add_topic(pretopic) + current_child.add_pretopic(pretopic) + + def to_json(self) -> str: + """ + Export the TopicList to a JSON string. + """ + + # Create dictionary + data: Dict[str, Any] = { + "owner": self.owner, + "name": self.name, + "description": self.description, + "topics": {}, + } + + # Add each topic + for topic_id, topic in self.topics.items(): + # Create topic + topic_data: Dict[str, Any] = { + "description": topic.description, + } + # Add subtopics + if topic.subtopics: + topic_data["subtopics"] = topic.subtopics + + # Add pretopics + if topic.pretopics: + topic_data["pretopics"] = topic.pretopics + + # Store in data + data["topics"][topic_id] = topic_data + + return json.dumps(data, indent=2) diff --git a/tests/TopicList_test.py b/tests/TopicList_test.py new file mode 100644 index 0000000..ee7895a --- /dev/null +++ b/tests/TopicList_test.py @@ -0,0 +1,416 @@ +"""Tests for the TopicList class.""" + +import json +from openproficiency import Topic, TopicList + + +class TestTopicList: + + # Initializers + def test_init_required_params(self): + """Create a topic list with required.""" + + # Arrange + owner = "github" + name = "github" + + # Act + topic_list = TopicList( + owner=owner, + name=name + ) + + # Assert + assert topic_list.owner == owner + assert topic_list.name == name + assert topic_list.topics == {} + assert topic_list.dependencies == {} + + def test_init_optional_params(self): + """Create a topic list with optional details.""" + + # Arrange + owner = "github" + name = "github" + description = "Features of the GitHub platform" + + # Act + topic_list = TopicList( + owner=owner, + name=name, + description=description + ) + + # Assert + assert topic_list.owner == owner + assert topic_list.name == name + assert topic_list.description == description + + # Methods + def test_add_topic_string(self): + """Add a new topic using a string ID.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="git" + ) + topic_id = "git-commit" + + # Act + topic_list.add_topic(topic_id) + + # Assert + assert "git-commit" in topic_list.topics + assert isinstance(topic_list.topics["git-commit"], Topic) + + def test_add_topic_topic(self): + """Add a new topic using a Topic instance.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="git" + ) + topic1 = Topic( + id="git-commit", + description="Storing changes to the Git history" + ) + + # Act + topic_list.add_topic(topic1) + + # Assert + assert "git-commit" in topic_list.topics + assert topic_list.topics["git-commit"] == topic1 + + def test_get_topic(self): + """Test retrieving a topic that exists in the list.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="git" + ) + topic = Topic(id="git-commit") + topic_list.topics[topic.id] = topic + + # Act + retrieved = topic_list.get_topic("git-commit") + + # Assert + assert retrieved is not None + assert retrieved.id == "git-commit" + + def test_get_topic_nonexistent(self): + """Test retrieving a topic that does not exist in the list.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="git" + ) + topic = Topic(id="git-commit") + topic_list.topics[topic.id] = topic + + # Act + retrieved = topic_list.get_topic("nonexistent") + + # Assert + assert retrieved is None + + # Properties + def test_full_name(self): + """Test getting the full name of the topic list.""" + + # Arrange + owner = "github" + name = "git" + topic_list = TopicList( + owner=owner, + name=name + ) + + # Act + full_name = topic_list.full_name + + # Assert + assert full_name == "github/git" + + # Methods - Class + def test_load_from_json_basic_info(self): + """Load a list with only list info.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform" + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # 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_simple(self): + """Load a list with only top-level topics.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform", + "topics": { + "actions": { + "description": "Storing changes to the Git history" + }, + "repositories": { + "description": "Versioning code with Git repositories" + } + } + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert - topics + assert "actions" in topic_list.topics + assert topic_list.topics["actions"].description == "Storing changes to the Git history" + + assert "repositories" in topic_list.topics + assert topic_list.topics["repositories"].description == "Versioning code with Git repositories" + + def test_load_from_json_subtopics(self): + """Load a list with subtopics.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform", + "topics": { + + "git-branch": { + "description": "Parallel versions of work", + "pretopic": ["git-commit"] + }, + + "actions": { + "description": "Storing changes to the Git history", + "subtopics": ["git-branch"] + }, + + "repositories": { + "description": "Versioning code with Git repositories", + "subtopics": [ + "commit-history", + "pull-request", + "fork" + ] + } + } + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert - topics + assert "actions" in topic_list.topics + assert "git-branch" in topic_list.topics + assert topic_list.topics["git-branch"].description == "Parallel versions of work" + + assert "repositories" in topic_list.topics + assert "commit-history" in topic_list.topics + assert "pull-request" in topic_list.topics + assert "fork" in topic_list.topics + + def test_load_from_json_subsubtopics(self): + """Load a list with multiple layers of subtopics.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github", + "description": "Features of the GitHub platform", + "topics": { + + "repositories": { + "description": "Versioning code with Git repositories", + "subtopics": [ + "commit-history", + { + "id": "community-files", + "description": "Essential files for repository community health", + "subtopics": [ + "code-of-conduct-file", + "codeowners-file", + "contributing-file", + "license-file", + "readme-file" + ] + }, + "pull-request", + "fork" + ] + } + } + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert + assert "community-files" in topic_list.topics + assert topic_list.topics["community-files"].description == "Essential files for repository community health" + assert "code-of-conduct-file" in topic_list.topics + assert "codeowners-file" in topic_list.topics + assert "contributing-file" in topic_list.topics + assert "license-file" in topic_list.topics + assert "readme-file" in topic_list.topics + + def test_load_from_json_pretopics(self): + """Load a list with subtopics.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform", + "topics": { + + "actions": { + "description": "Storing changes to the Git history", + "pretopics": ["yaml"] + }, + + "git-commit": { + "description": "Saving changes to the Git history" + }, + + "repositories": { + "description": "Versioning code with Git repositories", + "pretopics": [ + "git-commit", + "git-push", + "git-pull" + ] + } + } + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert - topics + assert "actions" in topic_list.topics + assert topic_list.topics["yaml"].description == "Storing changes to the Git history" + assert "yaml" in topic_list.topics + + assert "repositories" in topic_list.topics + assert topic_list.topics["repositories"].description == "Versioning code with Git repositories" + assert "git-commit" in topic_list.topics + assert "git-push" in topic_list.topics + assert "git-pull" in topic_list.topics + + def test_load_from_json_prepretopics(self): + """Load a list with multiple layers of pretopics.""" + + # Arrange + json_data = """ + { + "owner": "github", + "name": "github-features", + "description": "Features of the GitHub platform", + "topics": { + + "repositories": { + "description": "Versioning code with Git repositories", + "pretopics": [ + "git-commit", + { + "id": "git-merge", + "description": "Essential files for repository community health", + "pretopics": [ + "git1", + "git2", + "git3" + ] + }, + "git-pull" + ] + } + } + } + """ + + # Act + topic_list = TopicList.from_json(json_data) + + # Assert - topics + assert "repositories" in topic_list.topics + assert "git-commit" in topic_list.topics + assert topic_list.topics["git-commit"].description == "Versioning code with Git repositories" + + assert "git-merge" in topic_list.topics + assert topic_list.topics["git-merge"].description == "Essential files for repository community health" + assert "git1" in topic_list.topics + assert "git2" in topic_list.topics + assert "git3" in topic_list.topics + + assert "git-pull" in topic_list.topics + assert topic_list.topics["git-pull"].description == "Versioning code with Git repositories" + + def test_to_json_simple(self): + """Exporting a simple TopicList to JSON.""" + + # Arrange + topic_list = TopicList( + owner="github", + name="github-features", + description="Features of the GitHub platform", + ) + topic1 = Topic(id="actions", description="Storing changes to the Git history") + topic1.add_subtopic("automation") + topic1.add_pretopic("yaml") + + topic2 = Topic(id="repositories", description="Versioning code with Git repositories") + topic2.add_subtopic("git-clone") + topic2.add_pretopic("git-push") + + topic_list.add_topic(topic1) + topic_list.add_topic(topic2) + + # Act + json_data = topic_list.to_json() + data = json.loads(json_data) + + # Assert - List Info + assert data["owner"] == "github" + assert data["name"] == "github-features" + assert data["description"] == "Features of the GitHub platform" + + # Assert - Topic 1 + assert "actions" in data["topics"] + assert data["topics"]["actions"]["description"] == "Storing changes to the Git history" + assert "automation" in data["topics"]["actions"]["subtopics"] + assert "yaml" in data["topics"]["actions"]["pretopics"] + + # Assert - Topic 2 + assert "repositories" in data["topics"] + assert data["topics"]["repositories"]["description"] == "Versioning code with Git repositories" + assert "git-clone" in data["topics"]["repositories"]["subtopics"] + assert "git-push" in data["topics"]["repositories"]["pretopics"] From 9ef9ddc179cddc0295be19fe5920798810d04c64 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 17:09:13 +0000 Subject: [PATCH 04/14] feat: add requirements files --- requirements-dev.txt | 10 ++++++++++ requirements.txt | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b73d3a8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +# Testing +pytest>=6.0 +pytest-cov>=2.0 + +# Typing +mypy>=0.900 + +# Publishing +build==1.4.0 +twine==6.2.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..674cbf2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Production dependencies +# This package has no external runtime dependencies From 3aaacaa158b9a567c320b857ebeee49ff5354fe5 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 17:11:22 +0000 Subject: [PATCH 05/14] feat: add init to library folder --- openproficiency/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 openproficiency/__init__.py diff --git a/openproficiency/__init__.py b/openproficiency/__init__.py new file mode 100644 index 0000000..62a0752 --- /dev/null +++ b/openproficiency/__init__.py @@ -0,0 +1,4 @@ +"""OpenProficiency - Library to manage proficiency scores using topics and topic lists.""" + +from .Topic import Topic +from .TopicList import TopicList \ No newline at end of file From eba8080f9c28be051e1e3221951f9c6a8b94334c Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 17:48:44 +0000 Subject: [PATCH 06/14] feat: add vscode settings for python --- .vscode/settings.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b91dfa3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + // Python + "python.analysis.typeCheckingMode": "standard", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "tests" + ], + + // VS Code + "files.exclude": { + "*.pytest_cache": true, + "*.egg-info": true, + } +} \ No newline at end of file From 98dfbc91ec8f66eccc6ad88761f405aaed643260 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 19:27:52 +0000 Subject: [PATCH 07/14] feat: add workflow to run unit tests before merging to main --- .github/workflows/unit-tests.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..4f070c4 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,31 @@ +name: Unit Tests + +on: + pull_request: + branches: + - main + +jobs: + verify_unit_tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests with pytest + run: | + pytest From 7a44d6b97ba24424f61a3e7429614ada391de88a Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:22:14 +0000 Subject: [PATCH 08/14] fix: install the package locally to enable easier editing --- requirements-dev.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index b73d3a8..b19d3b1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,6 @@ +# Local package +-e . + # Testing pytest>=6.0 pytest-cov>=2.0 From c75d17169547b4d967f1456fc73799221e5bc75e Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:33:02 +0000 Subject: [PATCH 09/14] feat: add files to publish to PyPI --- CONTRIBUTE.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ MANIFEST.in | 4 ++++ pyproject.toml | 35 +++++++++++++++++++++++++++++++++++ setup.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 CONTRIBUTE.md create mode 100644 MANIFEST.in create mode 100644 pyproject.toml create mode 100644 setup.py diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..51a9219 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,45 @@ +## Verifying tests and coverage + +## Run Tests workflow + +Before creating a workflow, please run the [Unit Tests](./.github/workflows/unit-tests.yml) workflow locally using Act. + +```bash +act --job verify_unit_tests +``` + +```bash +act -W .github/workflows/unit-tests.yml --job verify_unit_tests +``` + +## Publish to PyPI + +1. Install tooling (run from the repo root). + + ```bash + python3 -m pip install --upgrade build twine + ``` + +2. Clean old artifacts. + + ```bash + rm -rf dist build *.egg-info + ``` + +3. Build the package (creates sdist and wheel in `dist/`). + + ```bash + python3 -m build + ``` + +4. Validate metadata (confirm long description renders). + + ```bash + python3 -m twine check dist/* + ``` + +5. Upload to PyPI. Twine will prompt for a password. Use your PyPI API token. + + ```bash + python3 -m twine upload dist/* + ``` diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9a2a096 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +recursive-include openproficiency *.py +recursive-include tests *.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a546ebe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "openproficiency" +version = "0.1.0" +description = "A simple library for managing proficiency topics and topic lists" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + { name = "Christopher W. Blake", email = "chriswblake@gmail.com" } +] +keywords = ["topic", "topic list", "proficiency", "learning", "knowledge"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +[project.urls] +Homepage = "https://github.com/openproficiency" +Documentation = "https://github.com/openproficiency/model" +Repository = "https://github.com/openproficiency/python-sdk.git" +Issues = "https://github.com/openproficiency/python-sdk/issues" + +[tool.setuptools] +packages = ["openproficiency"] \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c3a529f --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +"""Setup configuration for OpenProficiency library.""" + +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="openproficiency", + version="0.1.0", + author="OpenProficiency Contributors", + description="A library for managing proficiency topics and topic lists", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/openproficiency/openproficiency", + packages=find_packages(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.8", +) From cc230f0429aacbea897f1af9c8c56a51e6b82db6 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:41:46 +0000 Subject: [PATCH 10/14] feat: enhance Topic class to support initial subtopics and pretopics --- openproficiency/Topic.py | 28 +++++++++++++++++++++-- tests/Topic_test.py | 49 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/openproficiency/Topic.py b/openproficiency/Topic.py index bb5e228..667ddcd 100644 --- a/openproficiency/Topic.py +++ b/openproficiency/Topic.py @@ -12,6 +12,8 @@ def __init__( id: str, # Optional description: str = "", + subtopics: List[Union[str, "Topic"]] = [], + pretopics: List[Union[str, "Topic"]] = [] ): # Required self.id = id @@ -21,6 +23,10 @@ def __init__( self.subtopics: List[str] = [] self.pretopics: List[str] = [] + # Process initial subtopics and pretopics + self.add_subtopics(subtopics) + self.add_pretopics(pretopics) + # Methods def add_subtopic(self, subtopic: Union[str, "Topic"]) -> None: """ @@ -36,7 +42,16 @@ def add_subtopic(self, subtopic: Union[str, "Topic"]) -> None: self.subtopics.append(subtopic.id) else: - raise ValueError("Subtopic must be a string or a dictionary with an 'id' key.") + raise ValueError( + "Subtopic must be a string or a dictionary with an 'id' key.") + + def add_subtopics(self, subtopics: List[Union[str, "Topic"]]) -> None: + """ + Add multiple subtopics to this topic. + Supports a list of string IDs or Topic instances. + """ + for subtopic in subtopics: + self.add_subtopic(subtopic) def add_pretopic(self, pretopic: Union[str, "Topic"]) -> None: """ @@ -51,7 +66,16 @@ def add_pretopic(self, pretopic: Union[str, "Topic"]) -> None: elif isinstance(pretopic, Topic): self.pretopics.append(pretopic.id) else: - raise ValueError("Pretopic must be a string or a dictionary with an 'id' key.") + raise ValueError( + "Pretopic must be a string or a dictionary with an 'id' key.") + + def add_pretopics(self, pretopics: List[Union[str, "Topic"]]) -> None: + """ + Add multiple pretopics to this topic. + Supports a list of string IDs or Topic instances. + """ + for pretopic in pretopics: + self.add_pretopic(pretopic) # Debugging def __repr__(self) -> str: diff --git a/tests/Topic_test.py b/tests/Topic_test.py index bf76d6a..81f1287 100644 --- a/tests/Topic_test.py +++ b/tests/Topic_test.py @@ -27,17 +27,24 @@ def test_init_optional_params(self): # Arrange id = "git-commit" description = "Saving changes to the Git history" + subtopics = ["git-branch", "git-merge"] + pretopics = ["cli"] # Act topic = Topic( - id=id, - description=description + id=id, + description=description, + subtopics=subtopics, + pretopics=pretopics ) # Assert assert topic.description == description + assert len(topic.subtopics) == 2 + assert len(topic.pretopics) == 1 # Methods + def test_add_subtopic_string(self): """Test adding a subtopic as a string.""" @@ -67,6 +74,25 @@ def test_add_subtopic_topic(self): # Assert assert subtopic.id in topic.subtopics + def test_add_subtopics_mixed(self): + """Test adding multiple subtopics as a mix of strings and Topic instances.""" + + # Arrange + topic = Topic(id="git") + subtopic1 = "git-commit" + subtopic2 = Topic( + id="git-branch", + description="Managing branches in Git" + ) + subtopics = [subtopic1, subtopic2] + + # Act + topic.add_subtopics(subtopics) + + # Assert + assert subtopic1 in topic.subtopics + assert subtopic2.id in topic.subtopics + def test_add_pretopic_string(self): """Test adding a pretopic as a string.""" @@ -96,6 +122,25 @@ def test_add_pretopic_topic(self): # Assert assert pretopic.id in topic.pretopics + def test_add_pretopics_mixed(self): + """Test adding multiple pretopics as a mix of strings and Topic instances.""" + + # Arrange + topic = Topic(id="git") + pretopic1 = "version-control" + pretopic2 = Topic( + id="software-development", + description="The process of creating software" + ) + pretopics = [pretopic1, pretopic2] + + # Act + topic.add_pretopics(pretopics) + + # Assert + assert pretopic1 in topic.pretopics + assert pretopic2.id in topic.pretopics + # Debugging def test_topic_repr(self): """Check string representation of a Topic""" From 2bcc63895753d3c7de7cb4dad4e7216e9d73703a Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:49:54 +0000 Subject: [PATCH 11/14] docs: add dev container config --- .devcontainer/devcontainer.json | 27 +++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 4 ++++ 2 files changed, 31 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/postCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..45afab5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/base:jammy", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.10" + }, + "ghcr.io/dhoeric/features/act:1": {}, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, // Use this if running locally as a Dev Container + // "ghcr.io/devcontainers/features/docker-in-docker:2": {}, // Use this if running as a Codespace + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.debugpy", + "esbenp.prettier-vscode" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + } + } + }, + "postCreateCommand": "postCreate.sh" +} \ No newline at end of file diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100755 index 0000000..1750206 --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,4 @@ +# Install project dependencies +python -m pip install --upgrade pip +pip install -r requirements.txt +pip install -r requirements-dev.txt \ No newline at end of file From 59a53aab861d174ebca557d4239e6d3c9f7f4401 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:50:09 +0000 Subject: [PATCH 12/14] docs: start basic contribution guide --- CONTRIBUTE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 51a9219..7f8ea0d 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -1,4 +1,12 @@ -## Verifying tests and coverage +## How to Contribute + +1. Review the [python library issues]() for tasks for this implementation. +1. Review the [openproficiency model issues](https://github.com/openproficiency/model/issues) for broader tasks. +1. Fork this repository and make your changes. + > ❗️**Important:** They must be in alignment with the open [proficiency model](https://github.com/openproficiency/model) +1. Start the project in a Codespace or local development container. +1. Verify all unit tests pass. +1. Create a pull request. ## Run Tests workflow From f8e403cbc17c7a0bdb61af9307e6a899be99b76b Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 20:52:58 +0000 Subject: [PATCH 13/14] docs: add example usage of Topic and TopicList to Readme --- README.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b5fd3d0..824513d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,97 @@ # OpenProficiency - Python Library -A lightweight Python library for managing proficiency topics and topic lists. This library provides simple class objects for working with proficiency domains, making it easy to build applications that track and manage knowledge areas. +This library provides class objects for managing proficiency across knowledge domains. +## Features + +- **Topic Class**: A defined unique area of knowledge composed of subtopics and built upon pretopics (prerequisite topics). +- **TopicList Class**: A collection of related topics covering one knowledge domain. + +## Installation + +```bash +pip install openproficiency +``` + +## Quick Start + +### Create a Topic + +A **Topic** is an area of knowledge that a person gains proficiency in. If it has subtopics, the person can alternately gain proficiency in the parent topic by gaining proficiency in the subtopics. + +```python +from openproficiency import Topic + +# Create a simple topic and what composes it +topic_arithmetic = Topic( + id="arithmetic", + description="Basic operations for numeric calculations", + subtopics=["addition", "subtraction"] + pretopics=['writing'] +) +``` + +### Add a subtopic to an existing topic + +```python +# Specific other topics that compose the parent topic +topic_arithmetic.add_subtopic("multiplication") +topic_arithmetic.add_subtopic("division") +``` + +### Add a pretopic to an existing topic + +A **Pretopic** (prerequisite topic) is a topic that must be understood before a person can begin understanding the parent topic. + +```python +# Specify prerequisites to understand first +topic_arithmetic.add_pretopic("integers") +topic_arithmetic.add_pretopic("decimals") +topic_arithmetic.add_pretopic("fractions") +``` + +### Create a Topic List + +A topic list is a collection of topics that describe a specific knowledge domain. + +```python +from openproficiency import Topic, TopicList + +# Create an empty topic list +topic_list = TopicList( + owner="core-fundamentals", + name="math", + description="Math topics through basic calculus" +) + +# Add topics to the list +t_arithmetic = Topic( + id="arithmetic", + description="Basic operations for numeric calculations", + subtopics=[ + "addition", + "subtraction", + "multiplication", + "division + ] +) +topic_list.add_topic(t_arithmetic) + +t_algebra = Topic( + id="algebra", + description="Basic operations for numeric calculations", + subtopics=[ + "variables", + "constants", + "single-variable-equations", + "multiple-variable-equations" + ], + pretopics=[ "arithmetic" ] +) +``` + +## How to Develop + +This project is open to pull requests. + +Please see the [contribution guide](CONTRIBUTE.md) to get started. From 138d79395f2e599ac6e5ddd5ac0ecf0032a00ad3 Mon Sep 17 00:00:00 2001 From: "Christopher W. Blake" Date: Thu, 29 Jan 2026 21:08:54 +0000 Subject: [PATCH 14/14] docs: remove extra commands in publish instructions --- CONTRIBUTE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 7f8ea0d..be8e456 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -43,11 +43,12 @@ act -W .github/workflows/unit-tests.yml --job verify_unit_tests 4. Validate metadata (confirm long description renders). ```bash - python3 -m twine check dist/* + twine check dist/* ``` 5. Upload to PyPI. Twine will prompt for a password. Use your PyPI API token. ```bash - python3 -m twine upload dist/* + twine upload dist/* ``` +