Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,17 @@

- name: Test
run: make test-python-unit

doctrine:
name: Doctrine
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Doctrine tests
run: PYTHONPATH=src pytest src/julee/core/doctrine/
Comment on lines +54 to +65

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}

Copilot Autofix

AI 3 months ago

To fix this, explicitly restrict the GITHUB_TOKEN permissions in the workflow. The jobs only check out code and run Python tooling, so they only need read access to repository contents. The cleanest fix without changing behavior is to add a single permissions block at the workflow root (top level, alongside name and on). This will apply to all jobs (lint, unit, doctrine) unless overridden locally.

Concretely:

  • Edit .github/workflows/python.yml.
  • Insert:
permissions:
  contents: read

between the name: Python and the on: block (e.g., after line 1 or 2).
No imports or other definitions are required, and no steps or jobs need adjustment. This both documents and enforces least-privilege read-only access for the workflow.

Suggested changeset 1
.github/workflows/python.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -1,5 +1,8 @@
 name: Python
 
+permissions:
+  contents: read
+
 on:
   pull_request:
     paths:
EOF
@@ -1,5 +1,8 @@
name: Python

permissions:
contents: read

on:
pull_request:
paths:
Copilot is powered by AI and may make mistakes. Always verify output.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "julee"
version = "0.1.12"
version = "0.1.13"
description = "Julee - Clean architecture for accountable and transparent digital supply chains"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/julee/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Julee - Clean architecture for accountable and transparent digital supply chains."""

__version__ = "0.1.12"
__version__ = "0.1.13"
5 changes: 4 additions & 1 deletion src/julee/api/services/system_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any

from julee.domain.use_cases.initialize_system_data import (
InitializeSystemDataRequest,
InitializeSystemDataUseCase,
)

Expand Down Expand Up @@ -128,7 +129,9 @@ async def _execute_system_data_initialization(
try:
self.logger.debug("Starting task: %s", task_name)

await self.initialize_system_data_use_case.execute()
await self.initialize_system_data_use_case.execute(
InitializeSystemDataRequest()
)

results["tasks_completed"].append(task_name)
results["metadata"][task_name] = {
Expand Down
29 changes: 20 additions & 9 deletions src/julee/contrib/polling/apps/worker/pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
from julee.contrib.polling.infrastructure.temporal.proxies import (
WorkflowPollerServiceProxy,
)
from julee.contrib.polling.use_cases.poll_data import PollDataRequest, PollDataUseCase
from julee.contrib.polling.use_cases.poll_data import (
PollDataRequest,
PollDataUseCase,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -123,16 +126,12 @@ async def run(
handler=self.get_handler(),
analyzer=self.get_analyzer(),
)
result = await use_case.execute(request)
response = await use_case.execute(request)

self.endpoint_id = result.get("endpoint_id", self.endpoint_id)
self.has_new_data = result.get("detection_result", {}).get(
"has_new_data", False
)
self.endpoint_id = response.endpoint_id
self.has_new_data = response.new_items_found
self.current_step = "completed"

result["completed_at"] = workflow.now().isoformat()

workflow.logger.info(
"New data detection pipeline completed successfully",
extra={
Expand All @@ -141,7 +140,19 @@ async def run(
},
)

return result
return {
"polling_result": {
"content_hash": response.content_hash,
"content": response.content,
"polled_at": response.polled_at,
},
"detection_result": {
"has_new_data": response.new_items_found,
"current_hash": response.content_hash,
},
"endpoint_id": response.endpoint_id,
"completed_at": workflow.now().isoformat(),
}

except Exception as e:
self.current_step = "failed"
Expand Down
52 changes: 26 additions & 26 deletions src/julee/contrib/polling/use_cases/poll_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ class PollDataRequest(BaseModel):
previous_completion: dict | None = None


class PollDataResponse(BaseModel):
"""Output for PollDataUseCase."""

endpoint_id: str
content_hash: str
content: str
polled_at: str
new_items_found: bool
items_processed: int


class PollDataUseCase:
"""
Use case for polling an endpoint and detecting new data.
Expand All @@ -38,9 +49,8 @@ class PollDataUseCase:
3. Compare with the previous run's hash (from previous_completion)
4. If content has changed and an analyzer is set, identify new item IDs
5. Delegate to the optional PollingResultHandler with the item IDs
6. Return a completion result dict in the same shape that
NewDataDetectionPipeline returns, so Temporal schedule
last-completion-result works unchanged.
6. Return a PollDataResponse; the pipeline builds the Temporal
last-completion-result dict from it.

"""

Expand All @@ -54,7 +64,7 @@ def __init__(
self._handler = handler
self._analyzer = analyzer

async def execute(self, request: PollDataRequest) -> dict:
async def execute(self, request: PollDataRequest) -> PollDataResponse:
"""
Execute the poll-and-detect use case.

Expand All @@ -64,9 +74,7 @@ async def execute(self, request: PollDataRequest) -> dict:
for the first run).

Returns:
Completion result dict with keys:
polling_result, detection_result, handler_acknowledgement,
endpoint_id
PollDataResponse with polling outcome and detection results.
"""
config = request.config
endpoint_id = config.endpoint_identifier
Expand Down Expand Up @@ -99,13 +107,14 @@ async def execute(self, request: PollDataRequest) -> dict:
has_new_data = previous_hash != current_hash

# Step 5: Analyze and invoke handler if new data detected
handler_acknowledgement = None
items_processed = 0
if has_new_data:
try:
item_ids = await self._analyzer.identify_new_items(
previous_data, current_content
)
handler_acknowledgement = await self._handler.handle_new_data(
items_processed = len(item_ids)
await self._handler.handle_new_data(
endpoint_id,
item_ids,
current_hash,
Expand All @@ -120,22 +129,13 @@ async def execute(self, request: PollDataRequest) -> dict:
},
exc_info=True,
)
# handler_acknowledgement stays None to signal the exception

# Step 6: Return completion result
return {
"polling_result": {
"success": polling_result.success,
"content_hash": current_hash,
"content": current_content.decode("utf-8", errors="ignore"),
"polled_at": polled_at,
"content_length": len(current_content),
},
"detection_result": {
"has_new_data": has_new_data,
"previous_hash": previous_hash,
"current_hash": current_hash,
},
"handler_acknowledgement": handler_acknowledgement,
"endpoint_id": endpoint_id,
}
return PollDataResponse(
endpoint_id=endpoint_id,
content_hash=current_hash,
content=current_content.decode("utf-8", errors="ignore"),
polled_at=polled_at,
new_items_found=has_new_data,
items_processed=items_processed,
)
150 changes: 150 additions & 0 deletions src/julee/core/doctrine/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import importlib
import inspect

import pytest

Expand Down Expand Up @@ -216,3 +217,152 @@ async def test_all_use_cases_MUST_have_matching_response(self, repo):
assert not violations, "Use cases missing matching responses:\n" + "\n".join(
violations
)

@pytest.mark.asyncio
async def test_execute_MUST_accept_matching_request(self, repo):
"""execute() MUST declare its first parameter as {Prefix}Request.

Ensures the request class is part of the method's contract, not
just present in the module.
"""
use_case = ListUseCasesUseCase(repo)
response = await use_case.execute(ListCodeArtifactsRequest())

bounded_contexts = await repo.list_all()
import_paths = {bc.slug: bc.import_path for bc in bounded_contexts}

violations = []
suffix_len = len(USE_CASE_SUFFIX)
for artifact in response.artifacts:
name = artifact.artifact.name
ctx = artifact.bounded_context
if name in GENERIC_BASE_CLASSES:
continue
if not name.endswith(USE_CASE_SUFFIX):
continue

prefix = name[:-suffix_len]
expected_request = f"{prefix}{REQUEST_SUFFIX}"

bc_import_path = import_paths.get(ctx)
cls = (
_resolve_class(bc_import_path, artifact.artifact.file, name)
if bc_import_path
else None
)

if cls is not None:
execute = getattr(cls, "execute", None)
if not callable(execute):
continue
sig = inspect.signature(execute)
params = [p for p in sig.parameters.values() if p.name != "self"]
if not params:
violations.append(
f"{ctx}.{name}: execute() has no request parameter"
)
continue
annotation = params[0].annotation
actual_name = (
annotation.__name__
if hasattr(annotation, "__name__")
else str(annotation)
)
else:
# Fall back to AST-parsed signature
execute_method = next(
(m for m in artifact.artifact.methods if m.name == "execute"),
None,
)
if execute_method is None:
continue
if not execute_method.parameters:
violations.append(
f"{ctx}.{name}: execute() has no request parameter"
)
continue
actual_name = execute_method.parameters[0].type_annotation

if actual_name != expected_request:
violations.append(
f"{ctx}.{name}: execute() first parameter is '{actual_name}'"
f", expected '{expected_request}'"
)

assert not violations, "Use cases with wrong request type:\n" + "\n".join(
violations
)

@pytest.mark.asyncio
async def test_execute_MUST_return_matching_response(self, repo):
"""execute() MUST declare its return type as {Prefix}Response.

Ensures the response class is actually wired into execute(), not
just present in the module.
"""
use_case = ListUseCasesUseCase(repo)
response = await use_case.execute(ListCodeArtifactsRequest())

bounded_contexts = await repo.list_all()
import_paths = {bc.slug: bc.import_path for bc in bounded_contexts}

violations = []
suffix_len = len(USE_CASE_SUFFIX)
for artifact in response.artifacts:
name = artifact.artifact.name
ctx = artifact.bounded_context
if name in GENERIC_BASE_CLASSES:
continue
if not name.endswith(USE_CASE_SUFFIX):
continue

prefix = name[:-suffix_len]
expected_response = f"{prefix}{RESPONSE_SUFFIX}"

bc_import_path = import_paths.get(ctx)
cls = (
_resolve_class(bc_import_path, artifact.artifact.file, name)
if bc_import_path
else None
)

if cls is not None:
execute = getattr(cls, "execute", None)
if not callable(execute):
continue
sig = inspect.signature(execute)
return_annotation = sig.return_annotation
if return_annotation is inspect.Parameter.empty:
violations.append(
f"{ctx}.{name}: execute() has no return annotation"
)
continue
actual_name = (
return_annotation.__name__
if hasattr(return_annotation, "__name__")
else str(return_annotation)
)
else:
# Fall back to AST-parsed signature
execute_method = next(
(m for m in artifact.artifact.methods if m.name == "execute"),
None,
)
if execute_method is None:
continue
if not execute_method.return_type:
violations.append(
f"{ctx}.{name}: execute() has no return annotation"
)
continue
actual_name = execute_method.return_type

if actual_name != expected_response:
violations.append(
f"{ctx}.{name}: execute() returns '{actual_name}'"
f", expected '{expected_response}'"
)

assert not violations, "Use cases with wrong return type:\n" + "\n".join(
violations
)
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,16 @@ def _discover_all(self) -> list[BoundedContext]:
continue
if _is_gitignored(candidate, self.project_root):
continue
if candidate.name in RESERVED_WORDS:
continue
if not self._is_python_package(candidate):
continue

# Reserved words cannot be bounded contexts themselves, but may
# still be nested solution containers (e.g. contrib/, apps/).
is_reserved = candidate.name in RESERVED_WORDS

markers = self._detect_markers(candidate)

if self._is_bounded_context(markers):
if not is_reserved and self._is_bounded_context(markers):
# It's a bounded context
is_contrib = candidate.name == CONTRIB_DIR
context = BoundedContext(
Expand Down
Loading
Loading