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 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 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/.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 diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..be8e456 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,54 @@ +## 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 + +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 + twine check dist/* + ``` + +5. Upload to PyPI. Twine will prompt for a password. Use your PyPI API token. + + ```bash + 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/README.md b/README.md index be2ce23..824513d 100644 --- a/README.md +++ b/README.md @@ -1 +1,97 @@ -# python-sdk \ No newline at end of file +# OpenProficiency - Python Library + +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. diff --git a/openproficiency/Topic.py b/openproficiency/Topic.py new file mode 100644 index 0000000..667ddcd --- /dev/null +++ b/openproficiency/Topic.py @@ -0,0 +1,83 @@ +"""Topic module for OpenProficiency library.""" + +from typing import List, Union + + +class Topic: + + # Initializers + def __init__( + self, + # Required + id: str, + # Optional + description: str = "", + subtopics: List[Union[str, "Topic"]] = [], + pretopics: List[Union[str, "Topic"]] = [] + ): + # Required + self.id = id + + # Optional + self.description = description + 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: + """ + 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_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: + """ + 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.") + + 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: + """String representation of Topic.""" + return f"Topic(id='{self.id}', description='{self.description}')" 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/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 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/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b19d3b1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Local package +-e . + +# 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 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", +) 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"] diff --git a/tests/Topic_test.py b/tests/Topic_test.py new file mode 100644 index 0000000..81f1287 --- /dev/null +++ b/tests/Topic_test.py @@ -0,0 +1,157 @@ +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" + subtopics = ["git-branch", "git-merge"] + pretopics = ["cli"] + + # Act + topic = Topic( + 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.""" + + # 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_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.""" + + # 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 + + 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""" + + # 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