diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..25a75ea --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,34 @@ +name: Python Tests + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pip install poetry + + - name: Install dependencies + run: poetry install + + - name: Run unit tests + run: poetry run pytest diff --git a/README.md b/README.md index ba78ff5..802d70c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The Campus API Python library is a comprehensive client for interacting with the ## Tech Stack -- **Language:** Python 3.11 +- **Language:** Python 3.11 and 3.12 - **Package Management:** Poetry - **Dependencies:** - `flask` - Web framework (for integration) @@ -28,10 +28,12 @@ The Campus API Python library is a comprehensive client for interacting with the ### Prerequisites -- Python 3.11 +- Python 3.11 or 3.12 - Poetry - Access to Campus development environment (for server mode) +This library is intended to support both Python 3.11 and Python 3.12. + ### Installation 1. **Clone the repository** (if not already done): @@ -135,6 +137,8 @@ Run the test suite: poetry run pytest ``` +GitHub Actions runs the unit tests against both Python 3.11 and Python 3.12. + Unit tests are located in `tests/unit/`. ### Code Quality diff --git a/poetry.lock b/poetry.lock index 37adfad..4466e31 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "bcrypt" @@ -142,7 +142,7 @@ werkzeug = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus.git" reference = "weekly" -resolved_reference = "6e1685338a3d392c495423a664d9c7573312c9b7" +resolved_reference = "a303c3cdb01b2759aa3a2d1ce90f8826cb6e4295" [[package]] name = "certifi" @@ -300,12 +300,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "dnspython" @@ -388,6 +388,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -535,12 +547,28 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -618,6 +646,21 @@ files = [ {file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"}, ] +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pymongo" version = "4.16.0" @@ -712,6 +755,28 @@ snappy = ["python-snappy (>=0.6.0)"] test = ["importlib-metadata (>=7.0) ; python_version < \"3.13\"", "pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -818,4 +883,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "5dd7b5a17eded019a678bd4e927f3154b4166cbe261906e1d21b882e1f874619" +content-hash = "727c9d78cd3424798b39a7cdc7ec11ab3b95ee9502f4db829db43fa65faae29b" diff --git a/pyproject.toml b/pyproject.toml index c8bffb5..395d097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ python = ">=3.11,<3.14" flask = "^3.0.0" campus-suite = {git = "https://github.com/nyjc-computing/campus.git", branch = "weekly"} +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.2" + [[tool.poetry.packages]] include = "campus_python" diff --git a/tests/unit/test_interface.py b/tests/unit/test_interface.py index 5b89634..b679757 100644 --- a/tests/unit/test_interface.py +++ b/tests/unit/test_interface.py @@ -13,7 +13,11 @@ def setUp(self): self.mock_client.base_url = "https://api.example.com" def test_make_path_without_part(self): - """Test make_path() returns correct path without part.""" + """Test make_path() returns correct path without part. + + Resource roots don't need trailing slashes in url_prefix, but the + implementation should handle them correctly. + """ root = ResourceRoot(self.mock_client) root.url_prefix = "api/v1" self.assertEqual(root.make_path(), "/api/v1") @@ -43,17 +47,24 @@ def test_make_path_strips_both_leading_slashes(self): self.assertEqual(root.make_path("/users"), "/api/v1/users") def test_make_path_with_trailing_slash_in_prefix(self): - """Test make_path() with trailing slash in url_prefix.""" + """Test make_path() with trailing slash in url_prefix. + + Resource roots can have trailing slashes which are preserved. + """ root = ResourceRoot(self.mock_client) root.url_prefix = "api/v1/" # Trailing slash in prefix is preserved self.assertEqual(root.make_path(), "/api/v1/") def test_make_path_with_trailing_slash_in_part(self): - """Test make_path() with trailing slash in part.""" + """Test make_path() with trailing slash in part. + + This tests adding a collection path which should have trailing slash + according to API schema. + """ root = ResourceRoot(self.mock_client) root.url_prefix = "api/v1" - # Trailing slash in part is preserved + # Trailing slash in part is preserved (collection path) self.assertEqual(root.make_path("users/"), "/api/v1/users/") @@ -103,42 +114,60 @@ def setUp(self): self.root.url_prefix = "api/v1" def test_make_path_without_part(self): - """Test make_path() returns correct path without part.""" + """Test make_path() returns correct path without part. + + According to API schema, collections always have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users" - self.assertEqual(collection.make_path(), "/api/v1/users") + self.assertEqual(collection.make_path(), "/api/v1/users/") def test_make_path_with_part(self): - """Test make_path() returns correct path with part.""" + """Test make_path() returns correct path with part. + + According to API schema, single resources in collections have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users" - self.assertEqual(collection.make_path("123"), "/api/v1/users/123") + self.assertEqual(collection.make_path("123"), "/api/v1/users/123/") def test_make_path_strips_leading_slash_from_path(self): - """Test make_path() strips leading slash from collection path.""" + """Test make_path() strips leading slash from collection path. + + According to API schema, collections always have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "/users" - self.assertEqual(collection.make_path(), "/api/v1/users") + self.assertEqual(collection.make_path(), "/api/v1/users/") def test_make_path_strips_leading_slash_from_part(self): - """Test make_path() strips leading slash from part.""" + """Test make_path() strips leading slash from part. + + According to API schema, single resources in collections have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users" - self.assertEqual(collection.make_path("/123"), "/api/v1/users/123") + self.assertEqual(collection.make_path("/123"), "/api/v1/users/123/") def test_make_path_strips_trailing_slash_from_root_path(self): - """Test make_path() strips trailing slash from root path before adding part.""" + """Test make_path() preserves trailing slash from collection path. + + According to API schema, collection paths should have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users/" - # The implementation strips trailing slashes from the root path - self.assertEqual(collection.make_path("123"), "/api/v1/users/123") + # Collection paths with trailing slashes are preserved and normalized + self.assertEqual(collection.make_path("123"), "/api/v1/users/123/") def test_make_path_with_nested_path(self): - """Test make_path() with nested collection path.""" + """Test make_path() with nested collection path. + + According to API schema, all collection paths have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users/groups" - self.assertEqual(collection.make_path(), "/api/v1/users/groups") - self.assertEqual(collection.make_path("456"), "/api/v1/users/groups/456") + self.assertEqual(collection.make_path(), "/api/v1/users/groups/") + self.assertEqual(collection.make_path("456"), "/api/v1/users/groups/456/") class TestResourceCollectionMakeUrl(unittest.TestCase): @@ -152,21 +181,27 @@ def setUp(self): self.root.url_prefix = "api/v1" def test_make_url_without_part(self): - """Test make_url() returns correct URL without part.""" + """Test make_url() returns correct URL without part. + + According to API schema, collections have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users" self.assertEqual( collection.make_url(), - "https://api.example.com/api/v1/api/v1/users" + "https://api.example.com/api/v1/api/v1/users/" ) def test_make_url_with_part(self): - """Test make_url() returns correct URL with part.""" + """Test make_url() returns correct URL with part. + + According to API schema, single resources have trailing slashes. + """ collection = ResourceCollection(self.mock_client, root=self.root) collection.path = "users" self.assertEqual( collection.make_url("123"), - "https://api.example.com/api/v1/api/v1/users/123" + "https://api.example.com/api/v1/api/v1/users/123/" ) @@ -183,12 +218,28 @@ def setUp(self): self.collection.path = "users" def test_make_path_without_part(self): - """Test make_path() returns correct path without part.""" + """Test make_path() returns correct path without part. + + According to API schema, single resources should have trailing slashes. + The Resource implementation now correctly adds trailing slashes by default. + """ resource = Resource("123", parent=self.collection) - self.assertEqual(resource.make_path(), "/api/v1/users/123") + self.assertEqual(resource.make_path(), "/api/v1/users/123/") + + def test_make_path_without_part_with_trailing_slash(self): + """Test make_path() with end_slash=True follows API schema. + + According to API schema, single resources should have trailing slashes. + """ + resource = Resource("123", parent=self.collection) + self.assertEqual(resource.make_path(end_slash=True), "/api/v1/users/123/") def test_make_path_with_part(self): - """Test make_path() returns correct path with part.""" + """Test make_path() returns correct path with part. + + This tests dead-end subresources/actions which should NOT have trailing slashes + according to API schema. + """ resource = Resource("123", parent=self.collection) self.assertEqual(resource.make_path("profile"), "/api/v1/users/123/profile") @@ -203,9 +254,12 @@ def test_make_path_strips_trailing_slash_from_part(self): self.assertEqual(resource.make_path("profile/"), "/api/v1/users/123/profile") def test_make_path_with_multiple_parts_in_constructor(self): - """Test Resource construction with multiple parts.""" + """Test Resource construction with multiple parts. + + According to API schema, nested resources should have trailing slashes. + """ resource = Resource("123", "posts", "456", parent=self.collection) - self.assertEqual(resource.make_path(), "/api/v1/users/123/posts/456") + self.assertEqual(resource.make_path(), "/api/v1/users/123/posts/456/") def test_make_path_with_slashes_in_parts(self): """Test make_path() with leading/trailing slashes in constructor parts.""" @@ -234,11 +288,12 @@ def test_make_url_without_part(self): Note: This exhibits path duplication due to how Resource.make_url() combines parent.make_url() with self.make_path(). The parent's make_url() already includes the full path, then make_path() includes it again. + Updated to reflect trailing slash behavior from API schema. """ resource = Resource("123", parent=self.collection) self.assertEqual( resource.make_url(), - "https://api.example.com/api/v1/api/v1/users/api/v1/users/123" + "https://api.example.com/api/v1/api/v1/users//api/v1/users/123/" ) def test_make_url_with_part(self): @@ -247,11 +302,12 @@ def test_make_url_with_part(self): Note: This exhibits path duplication due to how Resource.make_url() combines parent.make_url() with self.make_path(). The parent's make_url() already includes the full path, then make_path() includes it again. + Updated to reflect trailing slash behavior from API schema. """ resource = Resource("123", parent=self.collection) self.assertEqual( resource.make_url("profile"), - "https://api.example.com/api/v1/api/v1/users/api/v1/users/123/profile" + "https://api.example.com/api/v1/api/v1/users//api/v1/users/123/profile" )