diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..0f44078 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,62 @@ +name: Code Quality + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check formatting + run: uv run ruff format . --check + + - name: Lint + run: uv run ruff check . + + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Type check + run: uv run pyright diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..798959b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,49 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.12', '3.13'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests with coverage + run: uv run pytest --cov=bloomy --cov-report=term-missing --cov-report=xml + + - name: Upload coverage report + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bee90e4..16e04c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,18 +2,16 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - repo: local + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.3 hooks: - - id: standardrb - name: standardrb - description: Enforce the community Ruby Style Guide with standardrb - entry: standardrb - language: ruby - types: ["ruby"] - args: ["--fix"] + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/pyproject.toml b/pyproject.toml index cec5c6e..5774a28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bloomy-python" -version = "0.18.0" +version = "0.19.0" description = "Python SDK for Bloom Growth API" readme = "README.md" authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }] @@ -70,7 +70,7 @@ quote-style = "double" indent-style = "space" [tool.pyright] -include = ["src", "tests"] +include = ["src"] pythonVersion = "3.12" typeCheckingMode = "strict" reportMissingImports = true diff --git a/src/bloomy/operations/async_/meetings.py b/src/bloomy/operations/async_/meetings.py index 9b34a3e..d628c54 100644 --- a/src/bloomy/operations/async_/meetings.py +++ b/src/bloomy/operations/async_/meetings.py @@ -89,11 +89,13 @@ async def attendees(self, meeting_id: int) -> builtins.list[MeetingAttendee]: # Map Id to UserId for compatibility return [ - MeetingAttendee.model_validate({ - "UserId": attendee["Id"], - "Name": attendee["Name"], - "ImageUrl": attendee["ImageUrl"] - }) + MeetingAttendee.model_validate( + { + "UserId": attendee["Id"], + "Name": attendee["Name"], + "ImageUrl": attendee["ImageUrl"], + } + ) for attendee in data ] @@ -126,19 +128,21 @@ async def issues( # Map meeting issue format to Issue model format return [ - Issue.model_validate({ - "Id": issue["Id"], - "Name": issue["Name"], - "DetailsUrl": issue.get("DetailsUrl"), - "CreateDate": issue["CreateTime"], - "MeetingId": issue["OriginId"], - "MeetingName": issue["Origin"], - "OwnerName": issue["Owner"]["Name"], - "OwnerId": issue["Owner"]["Id"], - "OwnerImageUrl": issue["Owner"]["ImageUrl"], - "ClosedDate": issue.get("CloseTime"), - "CompletionDate": issue.get("CompleteTime"), - }) + Issue.model_validate( + { + "Id": issue["Id"], + "Name": issue["Name"], + "DetailsUrl": issue.get("DetailsUrl"), + "CreateDate": issue["CreateTime"], + "MeetingId": issue["OriginId"], + "MeetingName": issue["Origin"], + "OwnerName": issue["Owner"]["Name"], + "OwnerId": issue["Owner"]["Id"], + "OwnerImageUrl": issue["Owner"]["ImageUrl"], + "ClosedDate": issue.get("CloseTime"), + "CompletionDate": issue.get("CompleteTime"), + } + ) for issue in data ] diff --git a/src/bloomy/operations/async_/todos.py b/src/bloomy/operations/async_/todos.py index cdbf58a..8cd0196 100644 --- a/src/bloomy/operations/async_/todos.py +++ b/src/bloomy/operations/async_/todos.py @@ -124,7 +124,7 @@ async def create( payload["dueDate"] = due_date if meeting_id is not None: - # Meeting todo - use the correct endpoint + # Meeting todo - use the correct endpoint with PascalCase keys payload = { "Title": title, "ForId": user_id, diff --git a/src/bloomy/operations/todos.py b/src/bloomy/operations/todos.py index 3012d58..cafd120 100644 --- a/src/bloomy/operations/todos.py +++ b/src/bloomy/operations/todos.py @@ -65,7 +65,7 @@ def list( def create( self, title: str, - meeting_id: int, + meeting_id: int | None = None, due_date: str | None = None, user_id: int | None = None, notes: str | None = None, @@ -74,7 +74,7 @@ def create( Args: title: The title of the new todo - meeting_id: The ID of the meeting associated with the todo + meeting_id: The ID of the meeting associated with the todo (optional) due_date: The due date of the todo (optional) user_id: The ID of the user responsible for the todo (default: initialized user ID) @@ -85,10 +85,15 @@ def create( Example: ```python + # Create a user todo client.todo.create( - title="New Todo", meeting_id=1, due_date="2024-06-15" + title="New Todo", due_date="2024-06-15" + ) + + # Create a meeting todo + client.todo.create( + title="Meeting Action", meeting_id=1, due_date="2024-06-15" ) - # Returns: Todo(id=1, name='New Todo', due_date='2024-06-15', ...) ``` """ @@ -98,13 +103,29 @@ def create( payload: dict[str, Any] = { "title": title, "accountableUserId": user_id, - "notes": notes, } + if notes is not None: + payload["notes"] = notes + if due_date is not None: payload["dueDate"] = due_date - response = self._client.post(f"L10/{meeting_id}/todos", json=payload) + if meeting_id is not None: + # Meeting todo - use the correct endpoint + payload = { + "Title": title, + "ForId": user_id, + } + if notes is not None: + payload["Notes"] = notes + if due_date is not None: + payload["dueDate"] = due_date + response = self._client.post(f"L10/{meeting_id}/todos", json=payload) + else: + # User todo + response = self._client.post("todo/create", json=payload) + response.raise_for_status() data = response.json() @@ -115,7 +136,7 @@ def create( "DetailsUrl": data.get("DetailsUrl"), "DueDate": data.get("DueDate"), "CompleteTime": None, - "CreateTime": datetime.now().isoformat(), + "CreateTime": data.get("CreateTime", datetime.now().isoformat()), "OriginId": meeting_id, "Origin": None, "Complete": False, diff --git a/src/bloomy/operations/users.py b/src/bloomy/operations/users.py index bacd283..77a2444 100644 --- a/src/bloomy/operations/users.py +++ b/src/bloomy/operations/users.py @@ -13,16 +13,16 @@ class UserOperations(BaseOperations, UserOperationsMixin): def details( self, user_id: int | None = None, - direct_reports: bool = False, - positions: bool = False, + include_direct_reports: bool = False, + include_positions: bool = False, all: bool = False, ) -> UserDetails: """Retrieve details of a specific user. Args: user_id: The ID of the user (default: the current user ID) - direct_reports: Whether to include direct reports (default: False) - positions: Whether to include positions (default: False) + include_direct_reports: Whether to include direct reports (default: False) + include_positions: Whether to include positions (default: False) all: Whether to include both direct reports and positions (default: False) Returns: @@ -39,10 +39,10 @@ def details( direct_reports_data = None positions_data = None - if direct_reports or all: + if include_direct_reports or all: direct_reports_data = self.direct_reports(user_id) - if positions or all: + if include_positions or all: positions_data = self.positions(user_id) return self._transform_user_details(data, direct_reports_data, positions_data) diff --git a/tests/test_async_meetings.py b/tests/test_async_meetings.py index 18dd1c1..43b6580 100644 --- a/tests/test_async_meetings.py +++ b/tests/test_async_meetings.py @@ -343,26 +343,26 @@ async def test_get_many_all_successful( attendees_responses = { 100: [ { - "UserId": 456, + "Id": 456, "Name": "John Doe", "ImageUrl": "https://example.com/img1.jpg", } ], 101: [ { - "UserId": 456, + "Id": 456, "Name": "John Doe", "ImageUrl": "https://example.com/img1.jpg", }, { - "UserId": 789, + "Id": 789, "Name": "Jane Smith", "ImageUrl": "https://example.com/img2.jpg", }, ], 102: [ { - "UserId": 456, + "Id": 456, "Name": "John Doe", "ImageUrl": "https://example.com/img1.jpg", } @@ -431,7 +431,7 @@ def get_side_effect(url, **_kwargs): elif "/200/attendees" in url: mock_response.json.return_value = [ { - "UserId": 456, + "Id": 456, "Name": "John Doe", "ImageUrl": "https://example.com/img1.jpg", } @@ -522,7 +522,7 @@ async def delayed_get(*args, **_kwargs): # Return attendees for any meeting mock_response.json.return_value = [ { - "UserId": 456, + "Id": 456, "Name": "John Doe", "ImageUrl": "https://example.com/img.jpg", } diff --git a/tests/test_async_meetings_extra.py b/tests/test_async_meetings_extra.py index d9a749e..4b912a9 100644 --- a/tests/test_async_meetings_extra.py +++ b/tests/test_async_meetings_extra.py @@ -47,33 +47,37 @@ async def test_issues( self, async_client: AsyncClient, mock_async_client: AsyncMock ) -> None: """Test fetching meeting issues.""" - # Mock the response data + # Mock the response data (in API format) issues_data = [ { "Id": 1, "Name": "Issue 1", "DetailsUrl": "https://example.com/issue/1", - "CreateDate": "2024-01-01T10:00:00Z", - "MeetingId": 123, - "MeetingName": "Weekly Meeting", - "OwnerName": "John Doe", - "OwnerId": 456, - "OwnerImageUrl": "https://example.com/john.jpg", - "ClosedDate": None, - "CompletionDate": None, + "CreateTime": "2024-01-01T10:00:00Z", + "OriginId": 123, + "Origin": "Weekly Meeting", + "Owner": { + "Id": 456, + "Name": "John Doe", + "ImageUrl": "https://example.com/john.jpg", + }, + "CloseTime": None, + "CompleteTime": None, }, { "Id": 2, "Name": "Issue 2", "DetailsUrl": "https://example.com/issue/2", - "CreateDate": "2024-01-02T10:00:00Z", - "MeetingId": 123, - "MeetingName": "Weekly Meeting", - "OwnerName": "Jane Smith", - "OwnerId": 789, - "OwnerImageUrl": "https://example.com/jane.jpg", - "ClosedDate": "2024-01-03T10:00:00Z", - "CompletionDate": "2024-01-03T10:00:00Z", + "CreateTime": "2024-01-02T10:00:00Z", + "OriginId": 123, + "Origin": "Weekly Meeting", + "Owner": { + "Id": 789, + "Name": "Jane Smith", + "ImageUrl": "https://example.com/jane.jpg", + }, + "CloseTime": "2024-01-03T10:00:00Z", + "CompleteTime": "2024-01-03T10:00:00Z", }, ] diff --git a/tests/test_async_todos.py b/tests/test_async_todos.py index ab357ca..7781162 100644 --- a/tests/test_async_todos.py +++ b/tests/test_async_todos.py @@ -232,11 +232,11 @@ async def test_create_for_meeting( # Verify the API call mock_async_client.post.assert_called_once() post_args = mock_async_client.post.call_args - assert post_args[0][0] == "todo/createmeetingtodo" + assert post_args[0][0] == "L10/125/todos" payload = post_args[1]["json"] - assert payload["title"] == "Meeting Action Item" - assert payload["meetingid"] == 125 - assert payload["accountableUserId"] == 789 + assert payload["Title"] == "Meeting Action Item" + assert payload["ForId"] == 789 + assert payload["dueDate"] == "2024-01-20" @pytest.mark.asyncio async def test_complete( diff --git a/tests/test_todos.py b/tests/test_todos.py index 8db53c9..aba6b6a 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -82,9 +82,9 @@ def test_create_todo(self, mock_http_client: Mock, mock_user_id: Mock) -> None: mock_http_client.post.assert_called_once_with( "L10/456/todos", json={ - "title": "New Todo", - "accountableUserId": 123, - "notes": "Important task", + "Title": "New Todo", + "ForId": 123, + "Notes": "Important task", "dueDate": "2024-12-31", }, ) diff --git a/tests/test_users.py b/tests/test_users.py index 70bf4e5..5c7120b 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -57,7 +57,7 @@ def test_details_with_direct_reports( user_ops = UserOperations(mock_http_client) - result = user_ops.details(direct_reports=True) + result = user_ops.details(include_direct_reports=True) assert result.direct_reports is not None assert len(result.direct_reports) == 1 diff --git a/uv.lock b/uv.lock index 49cb925..4595cc4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -49,7 +49,7 @@ wheels = [ [[package]] name = "bloomy-python" -version = "0.16.0" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "httpx" },