julee-polling 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. julee_polling/__init__.py +60 -0
  2. julee_polling/apps/__init__.py +17 -0
  3. julee_polling/apps/worker/__init__.py +17 -0
  4. julee_polling/apps/worker/pipelines.py +173 -0
  5. julee_polling/domain/__init__.py +15 -0
  6. julee_polling/domain/models/__init__.py +12 -0
  7. julee_polling/domain/models/polling_config.py +69 -0
  8. julee_polling/domain/services/__init__.py +0 -0
  9. julee_polling/domain/services/new_data_analyzer.py +50 -0
  10. julee_polling/domain/services/poller.py +39 -0
  11. julee_polling/domain/services/polling_result_handler.py +75 -0
  12. julee_polling/infrastructure/__init__.py +16 -0
  13. julee_polling/infrastructure/services/__init__.py +13 -0
  14. julee_polling/infrastructure/services/polling/__init__.py +13 -0
  15. julee_polling/infrastructure/services/polling/http/__init__.py +13 -0
  16. julee_polling/infrastructure/services/polling/http/http_poller_service.py +90 -0
  17. julee_polling/infrastructure/temporal/__init__.py +20 -0
  18. julee_polling/infrastructure/temporal/activities.py +46 -0
  19. julee_polling/infrastructure/temporal/activity_names.py +20 -0
  20. julee_polling/infrastructure/temporal/manager.py +308 -0
  21. julee_polling/infrastructure/temporal/proxies.py +45 -0
  22. julee_polling/py.typed +0 -0
  23. julee_polling/tests/__init__.py +6 -0
  24. julee_polling/tests/unit/__init__.py +6 -0
  25. julee_polling/tests/unit/apps/worker/test_pipelines.py +583 -0
  26. julee_polling/tests/unit/infrastructure/__init__.py +7 -0
  27. julee_polling/tests/unit/infrastructure/services/__init__.py +6 -0
  28. julee_polling/tests/unit/infrastructure/services/polling/__init__.py +6 -0
  29. julee_polling/tests/unit/infrastructure/services/polling/http/__init__.py +7 -0
  30. julee_polling/tests/unit/infrastructure/services/polling/http/test_http_poller_service.py +269 -0
  31. julee_polling/tests/unit/infrastructure/temporal/__init__.py +7 -0
  32. julee_polling/tests/unit/infrastructure/temporal/test_manager.py +475 -0
  33. julee_polling/usecases/__init__.py +0 -0
  34. julee_polling/usecases/poll_data.py +141 -0
  35. julee_polling-0.1.0.dist-info/METADATA +37 -0
  36. julee_polling-0.1.0.dist-info/RECORD +39 -0
  37. julee_polling-0.1.0.dist-info/WHEEL +5 -0
  38. julee_polling-0.1.0.dist-info/entry_points.txt +2 -0
  39. julee_polling-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,60 @@
1
+ """Polling: watch an external endpoint for new data.
2
+
3
+ A julee kit. It provides the domain model for a polling schedule, the
4
+ service protocols a poller implements, an HTTP poller, and a Temporal
5
+ pipeline that runs the whole thing durably.
6
+
7
+ Install it, then adopt it::
8
+
9
+ [tool.julee]
10
+ kits = ["polling"]
11
+
12
+ Example usage:
13
+ from julee_polling.domain.models.polling_config import (
14
+ PollingConfig,
15
+ PollingProtocol,
16
+ )
17
+ from julee_polling.infrastructure.services.polling.http import HttpPollerService
18
+ # Configure polling
19
+ config = PollingConfig(
20
+ endpoint_identifier="api-v1",
21
+ polling_protocol=PollingProtocol.HTTP,
22
+ connection_params={"url": "https://api.example.com/data"},
23
+ timeout_seconds=30
24
+ )
25
+
26
+ # Poll the endpoint
27
+ service = HttpPollerService()
28
+ result = await service.poll_endpoint(config)
29
+
30
+ Note: All imports must be explicit to avoid import chains that can pull
31
+ non-deterministic code into Temporal workflows. Import directly from
32
+ the specific modules you need rather than using this convenience module.
33
+ """
34
+
35
+ from julee.core.entities.kit import Kit
36
+
37
+ # No re-exports to avoid import chains that pull non-deterministic code
38
+ # into Temporal workflows. Import from specific submodules instead:
39
+ #
40
+ # Domain:
41
+ # - from julee_polling.domain.models.polling_config import PollingConfig, PollingProtocol, PollingResult
42
+ # - from julee_polling.domain.services.poller import PollerService
43
+ #
44
+ # Infrastructure:
45
+ # - from julee_polling.infrastructure.services.polling.http import HttpPollerService
46
+ # - from julee_polling.infrastructure.temporal.manager import PollingManager
47
+ # - from julee_polling.infrastructure.temporal.proxies import WorkflowPollerServiceProxy
48
+ # - from julee_polling.infrastructure.temporal.activities import TemporalPollerService
49
+
50
+ kit = Kit(
51
+ slug="polling",
52
+ name="Polling",
53
+ package="julee_polling",
54
+ contributes={
55
+ "temporal.pipelines": "julee_polling.apps.worker.pipelines",
56
+ "temporal.activities": "julee_polling.infrastructure.temporal.activities",
57
+ },
58
+ )
59
+
60
+ __all__ = ["kit"]
@@ -0,0 +1,17 @@
1
+ """
2
+ Application entry points for the polling contrib module.
3
+
4
+ This module contains the application-layer components that provide entry points
5
+ for the polling contrib module, including worker pipelines, API routes, and
6
+ CLI commands.
7
+
8
+ Following the ADR contrib module structure, this layer wires together domain
9
+ services and infrastructure implementations into runnable applications.
10
+
11
+ No re-exports to avoid import chains that pull non-deterministic code
12
+ into Temporal workflows. Import directly from specific modules:
13
+
14
+ - from julee_polling.apps.worker.pipelines import NewDataDetectionPipeline
15
+ """
16
+
17
+ __all__ = []
@@ -0,0 +1,17 @@
1
+ """
2
+ Worker applications for the polling contrib module.
3
+
4
+ This module contains worker-specific entry points for the polling contrib module,
5
+ including Temporal workflows (pipelines) that orchestrate polling operations
6
+ with durability guarantees.
7
+
8
+ The worker applications in this module can be registered with Temporal workers
9
+ to provide polling capabilities within workflow contexts.
10
+
11
+ No re-exports to avoid import chains that pull non-deterministic code
12
+ into Temporal workflows. Import directly from specific modules:
13
+
14
+ - from julee_polling.apps.worker.pipelines import NewDataDetectionPipeline
15
+ """
16
+
17
+ __all__ = []
@@ -0,0 +1,173 @@
1
+ """
2
+ Temporal workflows for polling operations in the Julee polling contrib module.
3
+
4
+ This module contains workflows that orchestrate polling operations with
5
+ Temporal's durability guarantees, providing retry logic, state management,
6
+ and reliable execution for endpoint polling and change detection.
7
+ """
8
+
9
+ import logging
10
+ from abc import abstractmethod
11
+ from typing import Any
12
+
13
+ from temporalio import workflow
14
+
15
+ from julee_polling.domain.models.polling_config import PollingConfig
16
+ from julee_polling.domain.services.new_data_analyzer import NewDataAnalyzer
17
+ from julee_polling.domain.services.polling_result_handler import (
18
+ PollingResultHandler,
19
+ )
20
+ from julee_polling.infrastructure.temporal.proxies import (
21
+ WorkflowPollerServiceProxy,
22
+ )
23
+ from julee_polling.usecases.poll_data import (
24
+ PollDataRequest,
25
+ PollDataUseCase,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @workflow.defn
32
+ class NewDataDetectionPipeline:
33
+ """
34
+ Temporal workflow for endpoint polling with new data detection.
35
+
36
+ This workflow:
37
+ 1. Polls an endpoint using the configured polling service
38
+ 2. Compares result with previous completion to detect changes
39
+ 3. Runs the analyzer to identify new item IDs (if provided)
40
+ 4. Hands off to result handler when new data is detected
41
+ 5. Returns completion result for next scheduled execution
42
+
43
+ The workflow uses Temporal's schedule last completion result feature
44
+ to automatically receive the previous execution's result for comparison.
45
+
46
+ Subclasses must implement get_handler() and get_analyzer() to supply the
47
+ appropriate objects for each polling use case (credential, product, etc.).
48
+ """
49
+
50
+ def __init__(self) -> None:
51
+ self.current_step = "initialized"
52
+ self.endpoint_id: str | None = None
53
+ self.has_new_data: bool = False
54
+
55
+ @abstractmethod
56
+ def get_handler(self) -> PollingResultHandler:
57
+ """Return the PollingResultHandler for this pipeline."""
58
+ ...
59
+
60
+ @abstractmethod
61
+ def get_analyzer(self) -> NewDataAnalyzer:
62
+ """Return the NewDataAnalyzer for this pipeline."""
63
+ ...
64
+
65
+ @workflow.query
66
+ def get_current_step(self) -> str:
67
+ """Query method to get the current workflow step."""
68
+ return self.current_step
69
+
70
+ @workflow.query
71
+ def get_endpoint_id(self) -> str | None:
72
+ """Query method to get the endpoint ID being polled."""
73
+ return self.endpoint_id
74
+
75
+ @workflow.query
76
+ def get_has_new_data(self) -> bool:
77
+ """Query method to check if new data was detected."""
78
+ return self.has_new_data
79
+
80
+ @workflow.run
81
+ async def run(
82
+ self,
83
+ config: PollingConfig | dict[str, Any],
84
+ ) -> dict[str, Any]:
85
+ """
86
+ Execute the new data detection workflow.
87
+
88
+ Args:
89
+ config: Configuration for the polling operation (PollingConfig or dict
90
+ from Temporal schedule serialisation)
91
+
92
+ Returns:
93
+ Completion result containing polling result and detection metadata
94
+
95
+ Raises:
96
+ RuntimeError: If polling fails after retries
97
+ """
98
+ # Temporal schedules serialise arguments as dicts, so accept either
99
+ # and work with the validated entity from here on.
100
+ polling_config = (
101
+ PollingConfig.model_validate(config) if isinstance(config, dict) else config
102
+ )
103
+
104
+ self.endpoint_id = polling_config.endpoint_identifier
105
+
106
+ previous_completion = workflow.get_last_completion_result()
107
+
108
+ workflow.logger.info(
109
+ "Starting new data detection pipeline",
110
+ extra={
111
+ "endpoint_id": self.endpoint_id,
112
+ "polling_protocol": polling_config.polling_protocol.value,
113
+ "has_previous_completion": previous_completion is not None,
114
+ "workflow_id": workflow.info().workflow_id,
115
+ "run_id": workflow.info().run_id,
116
+ },
117
+ )
118
+
119
+ self.current_step = "polling_endpoint"
120
+
121
+ try:
122
+ request = PollDataRequest(
123
+ config=polling_config,
124
+ previous_completion=previous_completion,
125
+ )
126
+ use_case = PollDataUseCase(
127
+ poller=WorkflowPollerServiceProxy(), # type: ignore[abstract]
128
+ handler=self.get_handler(),
129
+ analyzer=self.get_analyzer(),
130
+ )
131
+ response = await use_case.execute(request)
132
+
133
+ self.endpoint_id = response.endpoint_id
134
+ self.has_new_data = response.new_items_found
135
+ self.current_step = "completed"
136
+
137
+ workflow.logger.info(
138
+ "New data detection pipeline completed successfully",
139
+ extra={
140
+ "endpoint_id": self.endpoint_id,
141
+ "has_new_data": self.has_new_data,
142
+ },
143
+ )
144
+
145
+ return {
146
+ "polling_result": {
147
+ "content_hash": response.content_hash,
148
+ "content": response.content,
149
+ "polled_at": response.polled_at,
150
+ },
151
+ "detection_result": {
152
+ "has_new_data": response.new_items_found,
153
+ "current_hash": response.content_hash,
154
+ },
155
+ "endpoint_id": response.endpoint_id,
156
+ "completed_at": workflow.now().isoformat(),
157
+ }
158
+
159
+ except Exception as e:
160
+ self.current_step = "failed"
161
+
162
+ workflow.logger.error(
163
+ "New data detection pipeline failed",
164
+ extra={
165
+ "endpoint_id": self.endpoint_id,
166
+ "error": str(e),
167
+ "error_type": type(e).__name__,
168
+ "current_step": self.current_step,
169
+ },
170
+ exc_info=True,
171
+ )
172
+
173
+ raise
@@ -0,0 +1,15 @@
1
+ """
2
+ Domain layer for the polling contrib module.
3
+
4
+ This module contains the core domain models, services, and business rules
5
+ for the polling contrib module. It defines the fundamental concepts and
6
+ protocols that govern polling operations.
7
+
8
+ No re-exports to avoid import chains that pull non-deterministic code
9
+ into Temporal workflows. Import directly from specific modules:
10
+
11
+ - from julee_polling.domain.models.polling_config import PollingConfig, PollingProtocol, PollingResult
12
+ - from julee_polling.domain.services.poller import PollerService
13
+ """
14
+
15
+ __all__ = []
@@ -0,0 +1,12 @@
1
+ """
2
+ Polling domain models.
3
+
4
+ This module contains the core domain models for the polling contrib module.
5
+
6
+ No re-exports to avoid import chains that pull non-deterministic code
7
+ into Temporal workflows. Import directly from specific modules:
8
+
9
+ - from julee_polling.domain.models.polling_config import PollingConfig, PollingProtocol, PollingResult, SchedulingPolicy
10
+ """
11
+
12
+ __all__ = []
@@ -0,0 +1,69 @@
1
+ """
2
+ Polling domain models.
3
+
4
+ This module contains the core domain models for polling operations,
5
+ including configuration and result models.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from datetime import UTC, datetime
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from julee.core.entities.entity import Entity
14
+ from pydantic import Field, field_validator
15
+
16
+
17
+ class PollingProtocol(StrEnum):
18
+ """Supported polling protocols."""
19
+
20
+ HTTP = "http"
21
+
22
+
23
+ class SchedulingPolicy(StrEnum):
24
+ """Scheduling policy for polling operations."""
25
+
26
+ ALLOW_OVERLAP = "allow_overlap"
27
+ SKIP_IF_RUNNING = "skip_if_running"
28
+
29
+
30
+ class PollingConfig(Entity):
31
+ """Configuration for a polling operation."""
32
+
33
+ endpoint_identifier: str = Field(description="Unique identifier for this endpoint")
34
+ polling_protocol: PollingProtocol
35
+ connection_params: Mapping[str, Any] = Field(default_factory=dict)
36
+ polling_params: Mapping[str, Any] = Field(default_factory=dict)
37
+ timeout_seconds: int | None = Field(default=30)
38
+ scheduling_policy: SchedulingPolicy = Field(
39
+ default=SchedulingPolicy.ALLOW_OVERLAP,
40
+ description="Policy for handling overlapping polling operations",
41
+ )
42
+
43
+
44
+ class PollingResult(Entity):
45
+ """Result of a polling operation."""
46
+
47
+ success: bool
48
+ content: bytes
49
+ metadata: Mapping[str, Any] = Field(default_factory=dict)
50
+ polled_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
51
+ content_hash: str | None = None
52
+ error_message: str | None = None
53
+
54
+ @field_validator("content", mode="before")
55
+ @classmethod
56
+ def validate_content(cls, v):
57
+ """Convert list of integers to bytes (for Temporal serialization compatibility)."""
58
+ if isinstance(v, list):
59
+ # Temporal may serialize bytes as list of integers
60
+ return bytes(v)
61
+ elif isinstance(v, str):
62
+ # Handle string input
63
+ return v.encode("utf-8")
64
+ elif isinstance(v, bytes):
65
+ return v
66
+ else:
67
+ raise ValueError(
68
+ f"Content must be bytes, string, or list of integers, got {type(v)}"
69
+ )
File without changes
@@ -0,0 +1,50 @@
1
+ """
2
+ NewDataAnalyzer protocol for converting raw polling bytes into item IDs.
3
+
4
+ This protocol is the counterpart to PollingResultHandler. Where the handler
5
+ decides what to *do* when new items are found, the analyzer decides *what*
6
+ items are new by comparing previous and current polling payloads.
7
+
8
+ Separating analysis from handling keeps each concern to a single class and
9
+ allows the NewDataDetectionPipeline to complete data→domain translation
10
+ before any application-level dispatch occurs.
11
+ """
12
+
13
+ from typing import Protocol, runtime_checkable
14
+
15
+
16
+ @runtime_checkable
17
+ class NewDataAnalyzer(Protocol):
18
+ """
19
+ Converts raw polling bytes into a list of new item identifiers.
20
+
21
+ The analyzer is responsible for:
22
+ - Parsing the raw bytes from the polling response
23
+ - Comparing with the previous response (if any)
24
+ - Returning the IDs of items that are new or changed
25
+
26
+ The returned IDs are opaque strings from the polling system's perspective.
27
+ Their meaning is defined by the bounded context that provides the analyzer.
28
+ """
29
+
30
+ async def identify_new_items(
31
+ self,
32
+ previous_data: bytes | None,
33
+ new_data: bytes,
34
+ ) -> list[str]:
35
+ """
36
+ Identify items that are new or changed since the previous poll.
37
+
38
+ Args:
39
+ previous_data: Previous polling response content.
40
+ None if this is the first polling run.
41
+ new_data: Current polling response content.
42
+
43
+ Returns:
44
+ List of item identifier strings that are new or changed.
45
+ Empty list if nothing is new.
46
+
47
+ Raises:
48
+ ValueError: If the data cannot be parsed or is in an unexpected format.
49
+ """
50
+ ...
@@ -0,0 +1,39 @@
1
+ """
2
+ PollerService protocol for external endpoint polling operations.
3
+
4
+ This module defines the PollerService protocol that handles interactions
5
+ with various types of external endpoints for data polling and change detection.
6
+
7
+ Concrete implementations of this protocol are provided for different polling
8
+ mechanisms and are created via factory functions.
9
+ """
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from ..models.polling_config import PollingConfig, PollingResult
14
+
15
+
16
+ @runtime_checkable
17
+ class PollerService(Protocol):
18
+ """
19
+ Protocol for polling external endpoints for data.
20
+
21
+ This protocol defines the interface for a poller service that can perform
22
+ individual poll operations on different endpoint types. Implementations
23
+ handle the specifics of different polling mechanisms.
24
+ """
25
+
26
+ async def poll_endpoint(self, config: PollingConfig) -> PollingResult:
27
+ """
28
+ Poll an endpoint according to the provided configuration.
29
+
30
+ Args:
31
+ config: PollingConfig containing endpoint details and parameters
32
+
33
+ Returns:
34
+ PollingResult with success status, content, and metadata
35
+
36
+ Raises:
37
+ PollingError: When polling operation fails
38
+ """
39
+ ...
@@ -0,0 +1,75 @@
1
+ """
2
+ PollingResultHandler protocol for cross-bounded-context polling orchestration.
3
+
4
+ This module defines the PollingResultHandler protocol that enables cross-BC
5
+ coordination when new data is detected during polling operations. Following
6
+ ADR 003, this handler accepts domain-relevant arguments and allows the
7
+ solution provider to decide what happens with newly detected data.
8
+
9
+ The polling system recognizes the condition (new data detected) and hands off
10
+ to the handler without knowing what the handler does - this is the
11
+ "green-dotted-egg-handler" principle.
12
+
13
+ By the time handle_new_data() is called, the NewDataDetectionPipeline has
14
+ already translated raw bytes into item IDs via the NewDataAnalyzer. Handlers
15
+ therefore work with structured identifiers, not raw content.
16
+ """
17
+
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from julee.core.entities.acknowledgement import Acknowledgement
21
+
22
+
23
+ @runtime_checkable
24
+ class PollingResultHandler(Protocol):
25
+ """
26
+ Handler for new data detected during polling operations.
27
+
28
+ This protocol enables cross-bounded-context orchestration by allowing
29
+ polling systems to hand off newly detected item IDs to solution-specific
30
+ processing without knowing what that processing entails.
31
+
32
+ Handlers may implement any orchestration pattern:
33
+ - Start Temporal workflows
34
+ - Queue messages
35
+ - Trigger use cases directly
36
+ - Log and notify
37
+ - Complex multi-step processing
38
+
39
+ The handler receives item IDs (strings) rather than raw bytes because the
40
+ NewDataDetectionPipeline runs a NewDataAnalyzer before calling the handler.
41
+ This keeps use-case logic out of handlers and makes handlers pure dispatchers.
42
+ """
43
+
44
+ async def handle_new_data(
45
+ self,
46
+ endpoint_id: str,
47
+ new_item_ids: list[str],
48
+ content_hash: str,
49
+ ) -> Acknowledgement:
50
+ """
51
+ Handle newly detected items from a polling operation.
52
+
53
+ This method is called when the polling system detects that data at
54
+ an endpoint has changed and the analyzer has identified the new items.
55
+ The handler decides what to do with the item IDs — whether to start
56
+ processing workflows, queue work, send notifications, or any other
57
+ domain-specific action.
58
+
59
+ Args:
60
+ endpoint_id: Unique identifier for the polled endpoint
61
+ new_item_ids: List of item IDs identified as new or changed by the analyzer
62
+ content_hash: SHA256 hash of the new content for deduplication/tracking
63
+
64
+ Returns:
65
+ Acknowledgement indicating handler response:
66
+ - wilco: Handler will process the new data
67
+ - unable: Handler cannot process (resource constraints, invalid state, etc.)
68
+ - roger: Handler acknowledges but makes no processing commitment
69
+
70
+ Raises:
71
+ Exception: Handlers may raise exceptions for critical failures,
72
+ but should prefer returning Acknowledgement.unable() with
73
+ error details to avoid failing the polling workflow.
74
+ """
75
+ ...
@@ -0,0 +1,16 @@
1
+ """
2
+ Infrastructure layer for the polling contrib module.
3
+
4
+ This module contains the concrete implementations of domain protocols
5
+ and external system integrations for the polling contrib module.
6
+
7
+ No re-exports to avoid import chains that pull non-deterministic code
8
+ into Temporal workflows. Import directly from specific modules:
9
+
10
+ - from julee_polling.infrastructure.services.polling.http import HttpPollerService
11
+ - from julee_polling.infrastructure.temporal.manager import PollingManager
12
+ - from julee_polling.infrastructure.temporal.proxies import WorkflowPollerServiceProxy
13
+ - from julee_polling.infrastructure.temporal.activities import TemporalPollerService
14
+ """
15
+
16
+ __all__ = []
@@ -0,0 +1,13 @@
1
+ """
2
+ Infrastructure services for the polling contrib module.
3
+
4
+ This module contains the concrete implementations of domain services
5
+ for the polling contrib module.
6
+
7
+ No re-exports to avoid import chains that pull non-deterministic code
8
+ into Temporal workflows. Import directly from specific modules:
9
+
10
+ - from julee_polling.infrastructure.services.polling.http import HttpPollerService
11
+ """
12
+
13
+ __all__ = []
@@ -0,0 +1,13 @@
1
+ """
2
+ Polling infrastructure services.
3
+
4
+ This module contains the concrete implementations of polling services
5
+ for different protocols and mechanisms.
6
+
7
+ No re-exports to avoid import chains that pull non-deterministic code
8
+ into Temporal workflows. Import directly from specific modules:
9
+
10
+ - from julee_polling.infrastructure.services.polling.http import HttpPollerService
11
+ """
12
+
13
+ __all__ = []
@@ -0,0 +1,13 @@
1
+ """
2
+ HTTP polling implementation.
3
+
4
+ This module provides HTTP-specific polling functionality for the polling
5
+ contrib module.
6
+
7
+ No re-exports to avoid import chains that pull non-deterministic code
8
+ into Temporal workflows. Import directly from specific modules:
9
+
10
+ - from julee_polling.infrastructure.services.polling.http.http_poller_service import HttpPollerService
11
+ """
12
+
13
+ __all__ = []