dex-connector-hackernews 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.
- dex_connector_hackernews-0.1.0.dist-info/METADATA +47 -0
- dex_connector_hackernews-0.1.0.dist-info/RECORD +17 -0
- dex_connector_hackernews-0.1.0.dist-info/WHEEL +4 -0
- dex_connectors/hackernews/__init__.py +50 -0
- dex_connectors/hackernews/_generated/__init__.py +2 -0
- dex_connectors/hackernews/_generated/config.py +15 -0
- dex_connectors/hackernews/_generated/definitions.py +57 -0
- dex_connectors/hackernews/_generated/models.py +98 -0
- dex_connectors/hackernews/_generated/steps.py +131 -0
- dex_connectors/hackernews/_generated/timestamps.py +60 -0
- dex_connectors/hackernews/client.py +357 -0
- dex_connectors/hackernews/connection.py +109 -0
- dex_connectors/hackernews/dex-connector.json +82 -0
- dex_connectors/hackernews/hackernewsmock/__init__.py +119 -0
- dex_connectors/hackernews/models.py +36 -0
- dex_connectors/hackernews/py.typed +0 -0
- dex_connectors/hackernews/steps.py +35 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dex-connector-hackernews
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hacker News connector for Dex Flows
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: dex-connectors-sdk==0.1.0
|
|
8
|
+
Requires-Dist: dex-python-sdk>=1.28.0
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Hacker News connector for Python
|
|
13
|
+
|
|
14
|
+
Install with `pip install dex-connector-hackernews==0.1.0`. The package
|
|
15
|
+
requires `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and
|
|
16
|
+
implements the manifest Queries `listStoryIDs` and `getItem` with the same
|
|
17
|
+
branches, defaults, and failure classification as the Go module. The public
|
|
18
|
+
API needs no credentials.
|
|
19
|
+
|
|
20
|
+
## Operations
|
|
21
|
+
|
|
22
|
+
| Operation | Factory | Happy branch | Optional branches |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| `listStoryIDs` | `new_list_story_ids_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
|
|
25
|
+
| `getItem` | `new_get_item_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
|
|
26
|
+
|
|
27
|
+
`listStoryIDs` reads the `top` (default), `best`, `new`, `ask`, or `show` feed
|
|
28
|
+
and keeps the first `limit` IDs (default 50, at most 500), reporting whether
|
|
29
|
+
more remained. `getItem` returns a public story, comment, job, poll, or poll
|
|
30
|
+
option with its discussion URL; a missing, deleted, or dead item selects
|
|
31
|
+
`notFound`. 408, 429, 5xx, and transport failures retry; other non-200
|
|
32
|
+
responses select `providerRejected`. Item `text_html` is untrusted provider
|
|
33
|
+
HTML.
|
|
34
|
+
|
|
35
|
+
## Example
|
|
36
|
+
|
|
37
|
+
[`examples/top_story`](examples/top_story/flow.py) reads a feed's first story ID
|
|
38
|
+
and then that story. Run the Worker with
|
|
39
|
+
`uv run python -m examples.top_story.worker` after `dexcli dev` has loaded a
|
|
40
|
+
project containing the `hackernews` connection. It is smaller than the Go
|
|
41
|
+
daily-digest example, which also summarizes stories with an LLM.
|
|
42
|
+
|
|
43
|
+
## Tests
|
|
44
|
+
|
|
45
|
+
`hackernewsmock.Mock` scripts both Queries and defaults to the manifest
|
|
46
|
+
answers. Run `uv run pytest tests` for provider parity and manifest contract
|
|
47
|
+
checks.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
dex_connectors/hackernews/__init__.py,sha256=CRXdSt7bbr1RfQKh1ihjpHnrowbCt78gGh87hStj1oU,1179
|
|
2
|
+
dex_connectors/hackernews/client.py,sha256=PezLx_mJfVL2iW2-rJtYaoCXv0XisV6v_JKJ59MVIhY,13258
|
|
3
|
+
dex_connectors/hackernews/connection.py,sha256=55E2yFmIuIZ3_4bMUEVHNXvPmnk0BBhJV8Z03qugOYk,4635
|
|
4
|
+
dex_connectors/hackernews/dex-connector.json,sha256=zqC5F0EDhuYlmxyQzkj1E9fEKGjA8XZBFqz0WKTFSWM,1883
|
|
5
|
+
dex_connectors/hackernews/models.py,sha256=ofY_XYYae3HXIEUTerRilVhKQjZqRdN0TVsP1g-pPDY,1804
|
|
6
|
+
dex_connectors/hackernews/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
dex_connectors/hackernews/steps.py,sha256=YGSs3XTl1MaCpZmAwiiHjSCC3u77GcgbHvSCJunyvng,1256
|
|
8
|
+
dex_connectors/hackernews/_generated/__init__.py,sha256=ShfMsAGzSt4xuhCaf20ibgtfXl4pXomZb00ovUw6CeI,66
|
|
9
|
+
dex_connectors/hackernews/_generated/config.py,sha256=Auq3eXNAkoGs7TwYBOJi7RdwmLCPaoV8DwAlvW1OjKw,364
|
|
10
|
+
dex_connectors/hackernews/_generated/definitions.py,sha256=DrOM2bpfnI10aaPOxdPixHTekW87Ft9tDw8BtJmAFiA,1993
|
|
11
|
+
dex_connectors/hackernews/_generated/models.py,sha256=nhwJqLAE5mKFqqJ4FvHB7JHjM5E6EN--iIi4jV_cims,4833
|
|
12
|
+
dex_connectors/hackernews/_generated/steps.py,sha256=moI-sBrq8pgj4n5zwgpMEZFGVFy9mG_srxa9ZTfaZ30,5666
|
|
13
|
+
dex_connectors/hackernews/_generated/timestamps.py,sha256=Z8VwrE60w-33InIMgEMLiMPTFyRjj6oBq8tUro8Ssi8,3164
|
|
14
|
+
dex_connectors/hackernews/hackernewsmock/__init__.py,sha256=NEssHqCYtuN42tlwpJANIEf9bk0kLlN2fqdqNgkrQco,3732
|
|
15
|
+
dex_connector_hackernews-0.1.0.dist-info/METADATA,sha256=UKL-FUQG6M74uptpkE8rNw_Lw91VNne-H9b7QmVDJ-0,1960
|
|
16
|
+
dex_connector_hackernews-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
17
|
+
dex_connector_hackernews-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Hacker News feed and item Queries for Dex connector Steps."""
|
|
4
|
+
|
|
5
|
+
from .client import (
|
|
6
|
+
CONNECTOR_ID,
|
|
7
|
+
DEFECT,
|
|
8
|
+
FEEDS,
|
|
9
|
+
FOUND,
|
|
10
|
+
GET_ITEM_DEFINITION,
|
|
11
|
+
INVALID_RESPONSE,
|
|
12
|
+
LIST_STORY_IDS_DEFINITION,
|
|
13
|
+
LISTED,
|
|
14
|
+
NOT_FOUND,
|
|
15
|
+
PROVIDER_REJECTED,
|
|
16
|
+
Client,
|
|
17
|
+
Config,
|
|
18
|
+
GetItemOperation,
|
|
19
|
+
ListStoryIDsOperation,
|
|
20
|
+
)
|
|
21
|
+
from .connection import Connection, new_connection, new_connection_with_operations, new_project_connection
|
|
22
|
+
from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
|
|
23
|
+
from .steps import new_get_item_step, new_list_story_ids_step
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Client",
|
|
27
|
+
"Config",
|
|
28
|
+
"Connection",
|
|
29
|
+
"GetItemInput",
|
|
30
|
+
"GetItemOperation",
|
|
31
|
+
"Item",
|
|
32
|
+
"ListStoryIDsInput",
|
|
33
|
+
"ListStoryIDsOperation",
|
|
34
|
+
"StoryIDs",
|
|
35
|
+
"new_connection",
|
|
36
|
+
"new_connection_with_operations",
|
|
37
|
+
"new_get_item_step",
|
|
38
|
+
"new_list_story_ids_step",
|
|
39
|
+
"new_project_connection",
|
|
40
|
+
"CONNECTOR_ID",
|
|
41
|
+
"DEFECT",
|
|
42
|
+
"FEEDS",
|
|
43
|
+
"FOUND",
|
|
44
|
+
"GET_ITEM_DEFINITION",
|
|
45
|
+
"INVALID_RESPONSE",
|
|
46
|
+
"LIST_STORY_IDS_DEFINITION",
|
|
47
|
+
"LISTED",
|
|
48
|
+
"NOT_FOUND",
|
|
49
|
+
"PROVIDER_REJECTED",
|
|
50
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Config:
|
|
10
|
+
|
|
11
|
+
base_url: str = "https://hacker-news.firebaseio.com/v0"
|
|
12
|
+
timeout_seconds: float = 15
|
|
13
|
+
max_response_bytes: int = 1048576
|
|
14
|
+
|
|
15
|
+
setattr(Config, '__module__', 'dex_connectors.hackernews.client')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk import (
|
|
8
|
+
BranchDefinition,
|
|
9
|
+
MutationDefinition,
|
|
10
|
+
OperationRef,
|
|
11
|
+
QueryDefinition,
|
|
12
|
+
RetryPolicy,
|
|
13
|
+
StepDefaults,
|
|
14
|
+
default_connector_step_execute_retry_policy,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CONNECTOR_ID = "hacker-news-daily"
|
|
18
|
+
PROVIDER = "hacker-news"
|
|
19
|
+
LIST_STORY_IDS_OPERATION_ID = "listStoryIDs"
|
|
20
|
+
GET_ITEM_OPERATION_ID = "getItem"
|
|
21
|
+
DEFECT = "defect"
|
|
22
|
+
FOUND = "found"
|
|
23
|
+
INVALID_RESPONSE = "invalidResponse"
|
|
24
|
+
LISTED = "listed"
|
|
25
|
+
NOT_FOUND = "notFound"
|
|
26
|
+
PROVIDER_REJECTED = "providerRejected"
|
|
27
|
+
|
|
28
|
+
LIST_STORY_IDS_DEFINITION = QueryDefinition(
|
|
29
|
+
OperationRef(CONNECTOR_ID, LIST_STORY_IDS_OPERATION_ID),
|
|
30
|
+
(
|
|
31
|
+
BranchDefinition(LISTED, "The feed snapshot was read.", False),
|
|
32
|
+
BranchDefinition(PROVIDER_REJECTED, "Hacker News rejected the request.", True),
|
|
33
|
+
BranchDefinition(INVALID_RESPONSE, "Hacker News returned malformed or oversized data.", True),
|
|
34
|
+
BranchDefinition(DEFECT, "Local input or connection configuration is invalid.", True),
|
|
35
|
+
),
|
|
36
|
+
StepDefaults(
|
|
37
|
+
timedelta(seconds=30),
|
|
38
|
+
2,
|
|
39
|
+
execute_retry=RetryPolicy(timedelta(seconds=1), 2, timedelta(seconds=30), 0, timedelta(seconds=3900)),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
GET_ITEM_DEFINITION = QueryDefinition(
|
|
44
|
+
OperationRef(CONNECTOR_ID, GET_ITEM_OPERATION_ID),
|
|
45
|
+
(
|
|
46
|
+
BranchDefinition(FOUND, "The public item was read.", False),
|
|
47
|
+
BranchDefinition(NOT_FOUND, "The item is missing, deleted, or dead.", True),
|
|
48
|
+
BranchDefinition(PROVIDER_REJECTED, "Hacker News rejected the request.", True),
|
|
49
|
+
BranchDefinition(INVALID_RESPONSE, "Hacker News returned malformed or oversized data.", True),
|
|
50
|
+
BranchDefinition(DEFECT, "Local input or connection configuration is invalid.", True),
|
|
51
|
+
),
|
|
52
|
+
StepDefaults(
|
|
53
|
+
timedelta(seconds=30),
|
|
54
|
+
2,
|
|
55
|
+
execute_retry=RetryPolicy(timedelta(seconds=1), 2, timedelta(seconds=30), 0, timedelta(seconds=3900)),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field as _dataclass_field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .timestamps import parse_go_datetime, validate_go_datetime
|
|
10
|
+
|
|
11
|
+
def _replace_nil_slices(value: Any, depth: int) -> Any:
|
|
12
|
+
if value is None:
|
|
13
|
+
return ()
|
|
14
|
+
if depth == 1:
|
|
15
|
+
return tuple(value)
|
|
16
|
+
return tuple(_replace_nil_slices(item, depth - 1) for item in value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ListStoryIDsInput selects one feed snapshot. It does not select a historical time window.
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ListStoryIDsInput:
|
|
22
|
+
# Feed is top, best, new, ask, or show. Empty selects top.
|
|
23
|
+
feed: str = ''
|
|
24
|
+
# Limit bounds returned IDs from 1 through 500. Zero selects 50.
|
|
25
|
+
limit: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
setattr(ListStoryIDsInput, '__module__', 'dex_connectors.hackernews.models')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# StoryIDs contains the start of a feed in the provider's current display order.
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class StoryIDs:
|
|
34
|
+
# IDs contains at most the requested limit. Top stories can include jobs.
|
|
35
|
+
ids: tuple[int, ...] = ()
|
|
36
|
+
# HasMore reports that the API returned more IDs than this operation retained. The API has no page cursor.
|
|
37
|
+
has_more: bool = _dataclass_field(default=False, metadata={'dex_json_name': 'hasMore'})
|
|
38
|
+
# ObservedAt is the UTC time when this response was read.
|
|
39
|
+
observed_at: datetime = _dataclass_field(default_factory=lambda: datetime.min.replace(tzinfo=UTC), metadata={'dex_json_name': 'observedAt', 'dex_json_format': 'rfc3339'})
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if isinstance(self.observed_at, str):
|
|
43
|
+
object.__setattr__(self, 'observed_at', parse_go_datetime(self.observed_at, 'observed_at'))
|
|
44
|
+
elif self.observed_at is not None:
|
|
45
|
+
validate_go_datetime(self.observed_at, 'observed_at')
|
|
46
|
+
object.__setattr__(self, 'ids', _replace_nil_slices(self.ids, 1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
StoryIDs.__annotations__['ids'] = tuple[int, ...] | None
|
|
50
|
+
StoryIDs.__dataclass_fields__['ids'].type = tuple[int, ...] | None
|
|
51
|
+
setattr(StoryIDs, '__module__', 'dex_connectors.hackernews.models')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# GetItemInput identifies one story, comment, job, poll, or poll option.
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class GetItemInput:
|
|
57
|
+
# ID is a positive Hacker News item ID.
|
|
58
|
+
id: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
setattr(GetItemInput, '__module__', 'dex_connectors.hackernews.models')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Item preserves public API fields and adds the exact Hacker News permalink.
|
|
65
|
+
# TextHTML and Title are untrusted provider text. Applications must escape or sanitize them before HTML rendering.
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Item:
|
|
68
|
+
# ID identifies this item.
|
|
69
|
+
id: int = 0
|
|
70
|
+
# Type is story, comment, job, poll, or pollopt.
|
|
71
|
+
type: str = ''
|
|
72
|
+
# By is the author's public username, when available.
|
|
73
|
+
by: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
|
|
74
|
+
# Time is the creation time in Unix seconds, when available.
|
|
75
|
+
time: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
|
|
76
|
+
# Title is the provider's title, which can contain HTML entities.
|
|
77
|
+
title: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
|
|
78
|
+
# TextHTML is the submitted story or comment body. Linked article content is not included.
|
|
79
|
+
text: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
|
|
80
|
+
# URL is the submitted external URL. Ask HN and other text posts may have no URL.
|
|
81
|
+
url: str = _dataclass_field(default='', metadata={'dex_json_omit_default': True})
|
|
82
|
+
# DiscussionURL links directly to this story or comment on Hacker News.
|
|
83
|
+
discussion_url: str = _dataclass_field(default='', metadata={'dex_json_name': 'discussionUrl'})
|
|
84
|
+
# Score is the story score. Hacker News does not expose comment scores.
|
|
85
|
+
score: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
|
|
86
|
+
# Descendants is the provider's comment count for a story or poll.
|
|
87
|
+
descendants: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
|
|
88
|
+
# Parent identifies a comment's parent item or a poll option's poll.
|
|
89
|
+
parent: int = _dataclass_field(default=0, metadata={'dex_json_omit_default': True})
|
|
90
|
+
# Kids contains child IDs in the provider's ranked display order.
|
|
91
|
+
kids: tuple[int, ...] = _dataclass_field(default=(), metadata={'dex_json_omit_default': True})
|
|
92
|
+
# Deleted reports a deleted item. GetItem selects notFound instead of returning a tombstone.
|
|
93
|
+
deleted: bool = _dataclass_field(default=False, metadata={'dex_json_omit_default': True})
|
|
94
|
+
# Dead reports a moderated item. GetItem selects notFound instead of returning it.
|
|
95
|
+
dead: bool = _dataclass_field(default=False, metadata={'dex_json_omit_default': True})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
setattr(Item, '__module__', 'dex_connectors.hackernews.models')
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import dex
|
|
9
|
+
from dex_connectors.sdk import (
|
|
10
|
+
QueryResult,
|
|
11
|
+
QueryStep,
|
|
12
|
+
QueryStepConfig,
|
|
13
|
+
StepAnnotations,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ..connection import Connection
|
|
17
|
+
from ..models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
|
|
18
|
+
|
|
19
|
+
StepInput = TypeVar("StepInput")
|
|
20
|
+
|
|
21
|
+
def new_list_story_ids_step(
|
|
22
|
+
*,
|
|
23
|
+
step_type: str,
|
|
24
|
+
step_input_type: type[StepInput],
|
|
25
|
+
annotations: StepAnnotations | Mapping[str, str],
|
|
26
|
+
connection: Connection,
|
|
27
|
+
connection_name: str,
|
|
28
|
+
map_to_operation_input: Callable[[StepInput], ListStoryIDsInput],
|
|
29
|
+
listed: dex.StepTarget[QueryResult[StoryIDs]],
|
|
30
|
+
provider_rejected: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
|
|
31
|
+
invalid_response: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
|
|
32
|
+
defect: dex.StepTarget[QueryResult[StoryIDs]] | None = None,
|
|
33
|
+
result_attribute: dex.Attribute[QueryResult[StoryIDs]] | None = None,
|
|
34
|
+
step_options_override: dex.StepOptions | None = None,
|
|
35
|
+
) -> QueryStep[StepInput, ListStoryIDsInput, StoryIDs]:
|
|
36
|
+
if connection is None or connection.reference.name != connection_name:
|
|
37
|
+
raise ValueError("Hacker News Daily Step connection name must match its connection reference")
|
|
38
|
+
if isinstance(annotations, StepAnnotations):
|
|
39
|
+
step_annotations = annotations
|
|
40
|
+
else:
|
|
41
|
+
if not isinstance(annotations, Mapping) or set(annotations) != {"groupId", "groupLabel", "explanation"}:
|
|
42
|
+
raise ValueError("Hacker News Daily Step annotations require groupId, groupLabel, and explanation")
|
|
43
|
+
if any(not isinstance(value, str) for value in annotations.values()):
|
|
44
|
+
raise ValueError("Hacker News Daily Step annotations must be strings")
|
|
45
|
+
step_annotations = StepAnnotations(annotations["groupId"], annotations["groupLabel"], annotations["explanation"])
|
|
46
|
+
branches = {
|
|
47
|
+
"listed": listed,
|
|
48
|
+
}
|
|
49
|
+
if provider_rejected is not None:
|
|
50
|
+
branches["providerRejected"] = provider_rejected
|
|
51
|
+
if invalid_response is not None:
|
|
52
|
+
branches["invalidResponse"] = invalid_response
|
|
53
|
+
if defect is not None:
|
|
54
|
+
branches["defect"] = defect
|
|
55
|
+
return QueryStep(
|
|
56
|
+
QueryStepConfig(
|
|
57
|
+
step_type=step_type,
|
|
58
|
+
step_input_type=step_input_type,
|
|
59
|
+
annotations=step_annotations,
|
|
60
|
+
operation=_bound_operation(connection, "list_story_ids"),
|
|
61
|
+
connection=connection.reference,
|
|
62
|
+
map_to_operation_input=map_to_operation_input,
|
|
63
|
+
branches=branches,
|
|
64
|
+
result_attribute=result_attribute,
|
|
65
|
+
step_options_override=step_options_override,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
setattr(new_list_story_ids_step, '__module__', 'dex_connectors.hackernews.steps')
|
|
70
|
+
|
|
71
|
+
def new_get_item_step(
|
|
72
|
+
*,
|
|
73
|
+
step_type: str,
|
|
74
|
+
step_input_type: type[StepInput],
|
|
75
|
+
annotations: StepAnnotations | Mapping[str, str],
|
|
76
|
+
connection: Connection,
|
|
77
|
+
connection_name: str,
|
|
78
|
+
map_to_operation_input: Callable[[StepInput], GetItemInput],
|
|
79
|
+
found: dex.StepTarget[QueryResult[Item]],
|
|
80
|
+
not_found: dex.StepTarget[QueryResult[Item]] | None = None,
|
|
81
|
+
provider_rejected: dex.StepTarget[QueryResult[Item]] | None = None,
|
|
82
|
+
invalid_response: dex.StepTarget[QueryResult[Item]] | None = None,
|
|
83
|
+
defect: dex.StepTarget[QueryResult[Item]] | None = None,
|
|
84
|
+
result_attribute: dex.Attribute[QueryResult[Item]] | None = None,
|
|
85
|
+
step_options_override: dex.StepOptions | None = None,
|
|
86
|
+
) -> QueryStep[StepInput, GetItemInput, Item]:
|
|
87
|
+
if connection is None or connection.reference.name != connection_name:
|
|
88
|
+
raise ValueError("Hacker News Daily Step connection name must match its connection reference")
|
|
89
|
+
if isinstance(annotations, StepAnnotations):
|
|
90
|
+
step_annotations = annotations
|
|
91
|
+
else:
|
|
92
|
+
if not isinstance(annotations, Mapping) or set(annotations) != {"groupId", "groupLabel", "explanation"}:
|
|
93
|
+
raise ValueError("Hacker News Daily Step annotations require groupId, groupLabel, and explanation")
|
|
94
|
+
if any(not isinstance(value, str) for value in annotations.values()):
|
|
95
|
+
raise ValueError("Hacker News Daily Step annotations must be strings")
|
|
96
|
+
step_annotations = StepAnnotations(annotations["groupId"], annotations["groupLabel"], annotations["explanation"])
|
|
97
|
+
branches = {
|
|
98
|
+
"found": found,
|
|
99
|
+
}
|
|
100
|
+
if not_found is not None:
|
|
101
|
+
branches["notFound"] = not_found
|
|
102
|
+
if provider_rejected is not None:
|
|
103
|
+
branches["providerRejected"] = provider_rejected
|
|
104
|
+
if invalid_response is not None:
|
|
105
|
+
branches["invalidResponse"] = invalid_response
|
|
106
|
+
if defect is not None:
|
|
107
|
+
branches["defect"] = defect
|
|
108
|
+
return QueryStep(
|
|
109
|
+
QueryStepConfig(
|
|
110
|
+
step_type=step_type,
|
|
111
|
+
step_input_type=step_input_type,
|
|
112
|
+
annotations=step_annotations,
|
|
113
|
+
operation=_bound_operation(connection, "get_item"),
|
|
114
|
+
connection=connection.reference,
|
|
115
|
+
map_to_operation_input=map_to_operation_input,
|
|
116
|
+
branches=branches,
|
|
117
|
+
result_attribute=result_attribute,
|
|
118
|
+
step_options_override=step_options_override,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
setattr(new_get_item_step, '__module__', 'dex_connectors.hackernews.steps')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _bound_operation(connection: Connection, operation_name: str) -> Any:
|
|
126
|
+
source = getattr(connection, operation_name, None)
|
|
127
|
+
if source is None:
|
|
128
|
+
source = getattr(getattr(connection, "operations", None), operation_name, None)
|
|
129
|
+
if source is None:
|
|
130
|
+
raise ValueError(f"connection operation {operation_name} is missing")
|
|
131
|
+
return source() if callable(source) else source
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
"""Go time.Time JSON input parsing for generated connector models."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
_GO_TIME = re.compile(
|
|
10
|
+
r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{1,2}):([0-9]{2}):([0-9]{2})"
|
|
11
|
+
r"(?:[.,]([0-9]+))?(Z|([+-])([0-9]{2}):([0-9]{2}))$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parts(value: str, field_name: str) -> tuple[str, str, int, int]:
|
|
16
|
+
match = _GO_TIME.fullmatch(value)
|
|
17
|
+
if match is None:
|
|
18
|
+
raise ValueError(f"{field_name}: invalid Go time.Time JSON timestamp")
|
|
19
|
+
year, month, day, hour, minute, second, fraction, zone, sign, zone_hour, zone_minute = match.groups()
|
|
20
|
+
year_number, month_number, day_number = int(year), int(month), int(day)
|
|
21
|
+
is_leap = year_number % 4 == 0 and (year_number % 100 != 0 or year_number % 400 == 0)
|
|
22
|
+
month_days = (31, 29 if is_leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
23
|
+
if not (1 <= month_number <= 12 and 1 <= day_number <= month_days[month_number - 1]
|
|
24
|
+
and int(hour) <= 23 and int(minute) <= 59 and int(second) <= 59):
|
|
25
|
+
raise ValueError(f"{field_name}: invalid Go time.Time JSON timestamp")
|
|
26
|
+
offset_minutes = 0
|
|
27
|
+
if zone != "Z":
|
|
28
|
+
if int(zone_hour) > 24 or int(zone_minute) > 60:
|
|
29
|
+
raise ValueError(f"{field_name}: invalid Go time.Time JSON offset")
|
|
30
|
+
offset_minutes = (int(zone_hour) * 60 + int(zone_minute)) * (1 if sign == "+" else -1)
|
|
31
|
+
return year, f"{month}-{day}T{int(hour):02d}:{minute}:{second}", offset_minutes, int((fraction or "")[:9].ljust(9, "0"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def canonical_go_timestamp(value: str, field_name: str) -> str:
|
|
35
|
+
"""Parse Go's accepted JSON timestamp spellings and format like MarshalJSON."""
|
|
36
|
+
year, clock, offset_minutes, nanoseconds = _parts(value, field_name)
|
|
37
|
+
fraction = f"{nanoseconds:09d}".rstrip("0")
|
|
38
|
+
if abs(offset_minutes) >= 24 * 60:
|
|
39
|
+
return value
|
|
40
|
+
offset = "Z" if offset_minutes == 0 else f"{'+' if offset_minutes > 0 else '-'}{abs(offset_minutes) // 60:02d}:{abs(offset_minutes) % 60:02d}"
|
|
41
|
+
return f"{year}-{clock}{'.' + fraction if fraction else ''}{offset}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_go_datetime(value: str, field_name: str) -> datetime:
|
|
45
|
+
"""Decode a Go timestamp only when Python datetime preserves its value."""
|
|
46
|
+
year, _, offset_minutes, nanoseconds = _parts(value, field_name)
|
|
47
|
+
if int(year) == 0 or nanoseconds % 1000 or abs(offset_minutes) >= 24 * 60:
|
|
48
|
+
raise ValueError(f"{field_name}: Go time.Time JSON timestamp cannot be represented exactly as datetime")
|
|
49
|
+
canonical = canonical_go_timestamp(value, field_name)
|
|
50
|
+
return datetime.fromisoformat(canonical.replace("Z", "+00:00"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_go_datetime(value: datetime, field_name: str) -> datetime:
|
|
54
|
+
"""Reject Python datetimes that cannot round-trip through Go JSON time."""
|
|
55
|
+
if not isinstance(value, datetime):
|
|
56
|
+
raise ValueError(f"{field_name}: expected a datetime or Go time.Time JSON timestamp")
|
|
57
|
+
offset = value.utcoffset() if value.tzinfo is not None else None
|
|
58
|
+
if offset is None or offset.total_seconds() % 60:
|
|
59
|
+
raise ValueError(f"{field_name}: datetime requires a whole-minute timezone offset")
|
|
60
|
+
return value
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Hacker News public API reads and Go-compatible branch classification."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass as dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from datetime import timedelta as timedelta
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from dex_connectors.sdk import BranchDefinition as BranchDefinition
|
|
14
|
+
from dex_connectors.sdk import (
|
|
15
|
+
Call,
|
|
16
|
+
Failure,
|
|
17
|
+
FailureKind,
|
|
18
|
+
OperationRef,
|
|
19
|
+
QueryAttempt,
|
|
20
|
+
QueryDefinition,
|
|
21
|
+
Receipt,
|
|
22
|
+
)
|
|
23
|
+
from dex_connectors.sdk import StepDefaults as StepDefaults
|
|
24
|
+
from dex_connectors.sdk.providerhttp import (
|
|
25
|
+
BodyTooLargeError,
|
|
26
|
+
ProviderTransportError,
|
|
27
|
+
new_provider_http_client,
|
|
28
|
+
parse_retry_after,
|
|
29
|
+
read_bounded_body,
|
|
30
|
+
send_traced,
|
|
31
|
+
validate_base_url,
|
|
32
|
+
)
|
|
33
|
+
from dex_connectors.sdk.retry_policy import RetryPolicy as RetryPolicy
|
|
34
|
+
|
|
35
|
+
from ._generated import definitions as _generated_definitions
|
|
36
|
+
from ._generated.config import Config as _GeneratedConfig
|
|
37
|
+
from ._generated.definitions import DEFECT as DEFECT
|
|
38
|
+
from ._generated.definitions import FOUND as FOUND
|
|
39
|
+
from ._generated.definitions import GET_ITEM_OPERATION_ID as GET_ITEM_OPERATION_ID
|
|
40
|
+
from ._generated.definitions import INVALID_RESPONSE as INVALID_RESPONSE
|
|
41
|
+
from ._generated.definitions import LIST_STORY_IDS_OPERATION_ID as LIST_STORY_IDS_OPERATION_ID
|
|
42
|
+
from ._generated.definitions import LISTED as LISTED
|
|
43
|
+
from ._generated.definitions import NOT_FOUND as NOT_FOUND
|
|
44
|
+
from ._generated.definitions import PROVIDER_REJECTED as PROVIDER_REJECTED
|
|
45
|
+
from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
|
|
46
|
+
|
|
47
|
+
CONNECTOR_ID = "hackernews"
|
|
48
|
+
FAILURE_PROVIDER = "hacker-news"
|
|
49
|
+
FEEDS = ("top", "best", "new", "ask", "show")
|
|
50
|
+
ITEM_TYPES = ("story", "comment", "job", "poll", "pollopt")
|
|
51
|
+
_DEFAULT_BASE_URL = "https://hacker-news.firebaseio.com/v0"
|
|
52
|
+
_DEFAULT_TIMEOUT_SECONDS = 15
|
|
53
|
+
_DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
54
|
+
|
|
55
|
+
LIST_STORY_IDS_DEFINITION = QueryDefinition(
|
|
56
|
+
OperationRef(CONNECTOR_ID, LIST_STORY_IDS_OPERATION_ID),
|
|
57
|
+
_generated_definitions.LIST_STORY_IDS_DEFINITION.branches or (),
|
|
58
|
+
_generated_definitions.LIST_STORY_IDS_DEFINITION.step_defaults,
|
|
59
|
+
)
|
|
60
|
+
GET_ITEM_DEFINITION = QueryDefinition(
|
|
61
|
+
OperationRef(CONNECTOR_ID, GET_ITEM_OPERATION_ID),
|
|
62
|
+
_generated_definitions.GET_ITEM_DEFINITION.branches or (),
|
|
63
|
+
_generated_definitions.GET_ITEM_DEFINITION.step_defaults,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
Output = TypeVar("Output")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Config(_GeneratedConfig):
|
|
70
|
+
"""Hacker News HTTP settings; zero or blank values use manifest defaults."""
|
|
71
|
+
|
|
72
|
+
__slots__ = ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Client:
|
|
76
|
+
"""One redirect-safe HTTP client for the public, unauthenticated API."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, config: Config, http_client: httpx.AsyncClient | None = None) -> None:
|
|
79
|
+
if config is None:
|
|
80
|
+
raise ValueError("Hacker News configuration is required")
|
|
81
|
+
if config.timeout_seconds < 0 or config.max_response_bytes < 0:
|
|
82
|
+
raise ValueError("Hacker News timeout and maxResponseBytes must be positive")
|
|
83
|
+
self.base_url = validate_base_url(config.base_url or _DEFAULT_BASE_URL)
|
|
84
|
+
self.max_response_bytes = config.max_response_bytes or _DEFAULT_MAX_RESPONSE_BYTES
|
|
85
|
+
self.http_client = http_client or new_provider_http_client(
|
|
86
|
+
config.timeout_seconds or _DEFAULT_TIMEOUT_SECONDS
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def list_story_ids(self) -> "ListStoryIDsOperation":
|
|
90
|
+
"""Bind the feed snapshot Query to this client."""
|
|
91
|
+
return ListStoryIDsOperation(self)
|
|
92
|
+
|
|
93
|
+
def get_item(self) -> "GetItemOperation":
|
|
94
|
+
"""Bind the item Query to this client."""
|
|
95
|
+
return GetItemOperation(self)
|
|
96
|
+
|
|
97
|
+
async def aclose(self) -> None:
|
|
98
|
+
"""Close the owned provider HTTP client after application shutdown."""
|
|
99
|
+
await self.http_client.aclose()
|
|
100
|
+
|
|
101
|
+
async def get_json(
|
|
102
|
+
self, operation: str, path: str, empty: Output, decode: Callable[[bytes], QueryAttempt[Output]]
|
|
103
|
+
) -> QueryAttempt[Output]:
|
|
104
|
+
"""Send one GET and classify it exactly like the Go getJSON helper."""
|
|
105
|
+
try:
|
|
106
|
+
request = self.http_client.build_request(
|
|
107
|
+
"GET",
|
|
108
|
+
self.base_url + path,
|
|
109
|
+
headers={"Accept": "application/json", "User-Agent": "superdurable-hacker-news-daily/0.1"},
|
|
110
|
+
)
|
|
111
|
+
except (httpx.HTTPError, ValueError):
|
|
112
|
+
return _failed(
|
|
113
|
+
operation,
|
|
114
|
+
DEFECT,
|
|
115
|
+
empty,
|
|
116
|
+
FailureKind.VALIDATION,
|
|
117
|
+
"Hacker News request configuration is invalid",
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
response, _ = await send_traced(self.http_client, request)
|
|
121
|
+
except ProviderTransportError:
|
|
122
|
+
return _retry(operation, FailureKind.AVAILABILITY, "Hacker News request failed", 0)
|
|
123
|
+
try:
|
|
124
|
+
status = response.status_code
|
|
125
|
+
if status in (408, 429) or status >= 500:
|
|
126
|
+
kind = FailureKind.RATE_LIMIT if status == 429 else FailureKind.AVAILABILITY
|
|
127
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After", ""), datetime.now(UTC))
|
|
128
|
+
return _retry(operation, kind, "Hacker News is temporarily unavailable", retry_after)
|
|
129
|
+
if status == 404 and operation == GET_ITEM_OPERATION_ID:
|
|
130
|
+
return _failed(
|
|
131
|
+
operation, NOT_FOUND, empty, FailureKind.NOT_FOUND, "Hacker News resource was not found"
|
|
132
|
+
)
|
|
133
|
+
if status != 200:
|
|
134
|
+
return _failed(
|
|
135
|
+
operation,
|
|
136
|
+
PROVIDER_REJECTED,
|
|
137
|
+
empty,
|
|
138
|
+
FailureKind.PROVIDER_REJECTION,
|
|
139
|
+
"Hacker News rejected the request",
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
body = await read_bounded_body(response, self.max_response_bytes)
|
|
143
|
+
except BodyTooLargeError:
|
|
144
|
+
return _failed(
|
|
145
|
+
operation,
|
|
146
|
+
INVALID_RESPONSE,
|
|
147
|
+
empty,
|
|
148
|
+
FailureKind.RESPONSE_TOO_LARGE,
|
|
149
|
+
"Hacker News response exceeded the configured limit",
|
|
150
|
+
)
|
|
151
|
+
except ProviderTransportError:
|
|
152
|
+
return _retry(operation, FailureKind.AVAILABILITY, "Hacker News response was interrupted", 0)
|
|
153
|
+
return decode(body)
|
|
154
|
+
finally:
|
|
155
|
+
await response.aclose()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ListStoryIDsOperation:
|
|
159
|
+
"""Read a bounded snapshot of one Hacker News feed."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, client: Client) -> None:
|
|
162
|
+
if client is None:
|
|
163
|
+
raise ValueError("Hacker News client is required")
|
|
164
|
+
self.client = client
|
|
165
|
+
|
|
166
|
+
def definition(self) -> QueryDefinition:
|
|
167
|
+
"""Return manifest identity, branches, and retry defaults."""
|
|
168
|
+
return LIST_STORY_IDS_DEFINITION
|
|
169
|
+
|
|
170
|
+
async def invoke(self, call: Call, input: ListStoryIDsInput) -> QueryAttempt[StoryIDs]:
|
|
171
|
+
"""Validate the feed and limit, read once, then select a branch or safe retry."""
|
|
172
|
+
operation = LIST_STORY_IDS_OPERATION_ID
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(input, ListStoryIDsInput)
|
|
175
|
+
or not isinstance(input.feed, str)
|
|
176
|
+
or type(input.limit) is not int
|
|
177
|
+
):
|
|
178
|
+
return _failed(
|
|
179
|
+
operation, DEFECT, StoryIDs(), FailureKind.VALIDATION, "feed and limit are invalid"
|
|
180
|
+
)
|
|
181
|
+
feed = input.feed or "top"
|
|
182
|
+
limit = input.limit or 50
|
|
183
|
+
if feed not in FEEDS:
|
|
184
|
+
return _failed(
|
|
185
|
+
operation,
|
|
186
|
+
DEFECT,
|
|
187
|
+
StoryIDs(),
|
|
188
|
+
FailureKind.VALIDATION,
|
|
189
|
+
"feed must be top, best, new, ask, or show",
|
|
190
|
+
)
|
|
191
|
+
if not 1 <= limit <= 500:
|
|
192
|
+
return _failed(
|
|
193
|
+
operation, DEFECT, StoryIDs(), FailureKind.VALIDATION, "limit must be from 1 through 500"
|
|
194
|
+
)
|
|
195
|
+
return await self.client.get_json(
|
|
196
|
+
operation, f"/{feed}stories.json", StoryIDs(), lambda body: _decode_story_ids(body, limit)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class GetItemOperation:
|
|
201
|
+
"""Read one public Hacker News item."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, client: Client) -> None:
|
|
204
|
+
if client is None:
|
|
205
|
+
raise ValueError("Hacker News client is required")
|
|
206
|
+
self.client = client
|
|
207
|
+
|
|
208
|
+
def definition(self) -> QueryDefinition:
|
|
209
|
+
"""Return manifest identity, branches, and retry defaults."""
|
|
210
|
+
return GET_ITEM_DEFINITION
|
|
211
|
+
|
|
212
|
+
async def invoke(self, call: Call, input: GetItemInput) -> QueryAttempt[Item]:
|
|
213
|
+
"""Validate the item ID, read once, then select a branch or safe retry."""
|
|
214
|
+
operation = GET_ITEM_OPERATION_ID
|
|
215
|
+
if not isinstance(input, GetItemInput) or type(input.id) is not int or input.id <= 0:
|
|
216
|
+
return _failed(operation, DEFECT, Item(), FailureKind.VALIDATION, "item ID must be positive")
|
|
217
|
+
requested = input.id
|
|
218
|
+
return await self.client.get_json(
|
|
219
|
+
operation, f"/item/{requested}.json", Item(), lambda body: _decode_item(body, requested)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _failed(
|
|
224
|
+
operation: str, branch: str, value: Output, kind: FailureKind, message: str
|
|
225
|
+
) -> QueryAttempt[Output]:
|
|
226
|
+
return QueryAttempt.branch_result(
|
|
227
|
+
branch, value, Receipt(), Failure(kind, FAILURE_PROVIDER, operation, message)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _retry(operation: str, kind: FailureKind, message: str, after_seconds: float) -> QueryAttempt[Any]:
|
|
232
|
+
return QueryAttempt.retry(Failure(kind, FAILURE_PROVIDER, operation, message), after_seconds)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _decode_story_ids(body: bytes, limit: int) -> QueryAttempt[StoryIDs]:
|
|
236
|
+
operation = LIST_STORY_IDS_OPERATION_ID
|
|
237
|
+
try:
|
|
238
|
+
ids = json.loads(body)
|
|
239
|
+
if not isinstance(ids, list) or any(type(value) is not int for value in ids):
|
|
240
|
+
raise ValueError
|
|
241
|
+
except (ValueError, UnicodeDecodeError):
|
|
242
|
+
return _failed(
|
|
243
|
+
operation,
|
|
244
|
+
INVALID_RESPONSE,
|
|
245
|
+
StoryIDs(),
|
|
246
|
+
FailureKind.PROTOCOL,
|
|
247
|
+
"Hacker News returned an invalid feed",
|
|
248
|
+
)
|
|
249
|
+
if any(value <= 0 for value in ids):
|
|
250
|
+
return _failed(
|
|
251
|
+
operation,
|
|
252
|
+
INVALID_RESPONSE,
|
|
253
|
+
StoryIDs(),
|
|
254
|
+
FailureKind.PROTOCOL,
|
|
255
|
+
"Hacker News returned an invalid item ID",
|
|
256
|
+
)
|
|
257
|
+
return QueryAttempt.branch_result(
|
|
258
|
+
LISTED, StoryIDs(tuple(ids[:limit]), len(ids) > limit, datetime.now(UTC)), Receipt()
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _decode_item(body: bytes, requested: int) -> QueryAttempt[Item]:
|
|
263
|
+
operation = GET_ITEM_OPERATION_ID
|
|
264
|
+
try:
|
|
265
|
+
document = json.loads(body)
|
|
266
|
+
item = None if document is None else _item(document)
|
|
267
|
+
except (ValueError, TypeError, UnicodeDecodeError):
|
|
268
|
+
return _failed(
|
|
269
|
+
operation,
|
|
270
|
+
INVALID_RESPONSE,
|
|
271
|
+
Item(),
|
|
272
|
+
FailureKind.PROTOCOL,
|
|
273
|
+
"Hacker News returned malformed item JSON",
|
|
274
|
+
)
|
|
275
|
+
if item is None or item.deleted or item.dead:
|
|
276
|
+
return _failed(
|
|
277
|
+
operation, NOT_FOUND, Item(), FailureKind.NOT_FOUND, "Hacker News item is deleted or dead"
|
|
278
|
+
)
|
|
279
|
+
if item.id != requested:
|
|
280
|
+
return _failed(
|
|
281
|
+
operation,
|
|
282
|
+
INVALID_RESPONSE,
|
|
283
|
+
Item(),
|
|
284
|
+
FailureKind.PROTOCOL,
|
|
285
|
+
"Hacker News returned a different item ID",
|
|
286
|
+
)
|
|
287
|
+
if item.type not in ITEM_TYPES:
|
|
288
|
+
return _failed(
|
|
289
|
+
operation,
|
|
290
|
+
INVALID_RESPONSE,
|
|
291
|
+
Item(),
|
|
292
|
+
FailureKind.PROTOCOL,
|
|
293
|
+
"Hacker News returned an unknown item type",
|
|
294
|
+
)
|
|
295
|
+
if any(kid <= 0 for kid in item.kids):
|
|
296
|
+
return _failed(
|
|
297
|
+
operation,
|
|
298
|
+
INVALID_RESPONSE,
|
|
299
|
+
Item(),
|
|
300
|
+
FailureKind.PROTOCOL,
|
|
301
|
+
"Hacker News returned an invalid child ID",
|
|
302
|
+
)
|
|
303
|
+
return QueryAttempt.branch_result(FOUND, item, Receipt())
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _item(document: Any) -> Item:
|
|
307
|
+
if not isinstance(document, dict):
|
|
308
|
+
raise ValueError
|
|
309
|
+
kids = document.get("kids")
|
|
310
|
+
if kids is None:
|
|
311
|
+
kids = []
|
|
312
|
+
if not isinstance(kids, list) or any(type(kid) is not int for kid in kids):
|
|
313
|
+
raise ValueError
|
|
314
|
+
item_id = _integer(document, "id")
|
|
315
|
+
return Item(
|
|
316
|
+
id=item_id,
|
|
317
|
+
type=_string(document, "type"),
|
|
318
|
+
discussion_url=f"https://news.ycombinator.com/item?id={item_id}",
|
|
319
|
+
by=_string(document, "by"),
|
|
320
|
+
time=_integer(document, "time"),
|
|
321
|
+
title=_string(document, "title"),
|
|
322
|
+
text_html=_string(document, "text"),
|
|
323
|
+
url=_string(document, "url"),
|
|
324
|
+
score=_integer(document, "score"),
|
|
325
|
+
descendants=_integer(document, "descendants"),
|
|
326
|
+
parent=_integer(document, "parent"),
|
|
327
|
+
kids=tuple(kids),
|
|
328
|
+
deleted=_boolean(document, "deleted"),
|
|
329
|
+
dead=_boolean(document, "dead"),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _string(document: dict[str, Any], name: str) -> str:
|
|
334
|
+
value = document.get(name)
|
|
335
|
+
if value is None:
|
|
336
|
+
return ""
|
|
337
|
+
if not isinstance(value, str):
|
|
338
|
+
raise ValueError
|
|
339
|
+
return value
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _integer(document: dict[str, Any], name: str) -> int:
|
|
343
|
+
value = document.get(name)
|
|
344
|
+
if value is None:
|
|
345
|
+
return 0
|
|
346
|
+
if type(value) is not int:
|
|
347
|
+
raise ValueError
|
|
348
|
+
return value
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _boolean(document: dict[str, Any], name: str) -> bool:
|
|
352
|
+
value = document.get(name)
|
|
353
|
+
if value is None:
|
|
354
|
+
return False
|
|
355
|
+
if not isinstance(value, bool):
|
|
356
|
+
raise ValueError
|
|
357
|
+
return value
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# connectorctl: handwritten facade
|
|
4
|
+
"""Hacker News connection for a named project connection."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from dex_connectors.sdk import ConnectionRef, Query
|
|
10
|
+
from dex_connectors.sdk.projectconfig import LoadedProject
|
|
11
|
+
|
|
12
|
+
from .client import CONNECTOR_ID, GET_ITEM_DEFINITION, LIST_STORY_IDS_DEFINITION, Client, Config
|
|
13
|
+
from .models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Connection:
|
|
17
|
+
"""Nonserializable Hacker News operation bindings for a named project connection."""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_list_story_ids", "_get_item", "_client", "reference")
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
reference: ConnectionRef,
|
|
24
|
+
list_story_ids: Query[ListStoryIDsInput, StoryIDs],
|
|
25
|
+
get_item: Query[GetItemInput, Item],
|
|
26
|
+
client: Client | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
reference.validate()
|
|
29
|
+
if reference.provider != CONNECTOR_ID or list_story_ids is None or get_item is None:
|
|
30
|
+
raise ValueError("Hacker News connection provider and operations are required")
|
|
31
|
+
if (
|
|
32
|
+
list_story_ids.definition().operation != LIST_STORY_IDS_DEFINITION.operation
|
|
33
|
+
or get_item.definition().operation != GET_ITEM_DEFINITION.operation
|
|
34
|
+
):
|
|
35
|
+
raise ValueError("Hacker News connection operation identity differs")
|
|
36
|
+
self.reference = reference
|
|
37
|
+
self._list_story_ids = list_story_ids
|
|
38
|
+
self._get_item = get_item
|
|
39
|
+
self._client = client
|
|
40
|
+
|
|
41
|
+
def list_story_ids(self) -> Query[ListStoryIDsInput, StoryIDs]:
|
|
42
|
+
"""Return the feed snapshot Query used by an application Step."""
|
|
43
|
+
return self._list_story_ids
|
|
44
|
+
|
|
45
|
+
def get_item(self) -> Query[GetItemInput, Item]:
|
|
46
|
+
"""Return the item Query used by an application Step."""
|
|
47
|
+
return self._get_item
|
|
48
|
+
|
|
49
|
+
async def aclose(self) -> None:
|
|
50
|
+
"""Close the owned provider transport after Worker shutdown."""
|
|
51
|
+
if self._client is not None:
|
|
52
|
+
await self._client.aclose()
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
"""Keep provider transport out of logs."""
|
|
56
|
+
return "hackernews.Connection([REDACTED])"
|
|
57
|
+
|
|
58
|
+
def __reduce__(self) -> tuple[object, ...]:
|
|
59
|
+
"""Prevent a live connection from entering durable Flow state."""
|
|
60
|
+
raise TypeError("Hacker News connections cannot be serialized")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def new_connection(client: Client, reference: ConnectionRef) -> Connection:
|
|
64
|
+
"""Bind a client to an explicit logical connection reference."""
|
|
65
|
+
if client is None:
|
|
66
|
+
raise ValueError("Hacker News client is required")
|
|
67
|
+
return Connection(reference, client.list_story_ids(), client.get_item(), client)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def new_connection_with_operations(
|
|
71
|
+
list_story_ids: Query[ListStoryIDsInput, StoryIDs],
|
|
72
|
+
get_item: Query[GetItemInput, Item],
|
|
73
|
+
reference: ConnectionRef,
|
|
74
|
+
) -> Connection:
|
|
75
|
+
"""Bind mock operations to the same connection identity as the real provider."""
|
|
76
|
+
return Connection(reference, list_story_ids, get_item)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def new_project_connection(
|
|
80
|
+
project: LoadedProject, connection_name: str, http_client: httpx.AsyncClient | None = None
|
|
81
|
+
) -> Connection:
|
|
82
|
+
"""Open the declared connection settings; the public API needs no credentials."""
|
|
83
|
+
if project is None:
|
|
84
|
+
raise ValueError("Hacker News connection requires loaded project configuration")
|
|
85
|
+
reference = ConnectionRef(CONNECTOR_ID, connection_name)
|
|
86
|
+
reference.validate()
|
|
87
|
+
matching = [
|
|
88
|
+
entry
|
|
89
|
+
for entry in project.configuration.connections
|
|
90
|
+
if entry.connector_id == CONNECTOR_ID and entry.connection_name == connection_name
|
|
91
|
+
]
|
|
92
|
+
if len(matching) != 1:
|
|
93
|
+
raise ValueError("Hacker News project connection is missing or duplicated")
|
|
94
|
+
if matching[0].provider != CONNECTOR_ID:
|
|
95
|
+
raise ValueError("Hacker News project connection provider differs")
|
|
96
|
+
return new_connection(Client(_decode_config(matching[0].configuration), http_client), reference)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decode_config(value: dict[str, Any]) -> Config:
|
|
100
|
+
if set(value) - {"baseUrl", "timeout", "maxResponseBytes"}:
|
|
101
|
+
raise ValueError("Hacker News configuration has unknown fields")
|
|
102
|
+
base_url = value.get("baseUrl", "")
|
|
103
|
+
timeout = value.get("timeout", 0)
|
|
104
|
+
max_response_bytes = value.get("maxResponseBytes", 0)
|
|
105
|
+
if not isinstance(base_url, str):
|
|
106
|
+
raise ValueError("Hacker News configuration baseUrl must be a string")
|
|
107
|
+
if type(timeout) is not int or type(max_response_bytes) is not int:
|
|
108
|
+
raise ValueError("Hacker News configuration timeout and maxResponseBytes must be integers")
|
|
109
|
+
return Config(base_url, timeout / 1_000_000_000, max_response_bytes)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"connectorId": "hacker-news-daily",
|
|
3
|
+
"releaseTag": "connectors/hackernews/go/v0.23.0",
|
|
4
|
+
"package": {
|
|
5
|
+
"ecosystem": "pypi",
|
|
6
|
+
"name": "dex-connector-hackernews",
|
|
7
|
+
"version": "0.1.0"
|
|
8
|
+
},
|
|
9
|
+
"factories": [
|
|
10
|
+
{
|
|
11
|
+
"name": "new_list_story_ids_step",
|
|
12
|
+
"kind": "query",
|
|
13
|
+
"operationId": "listStoryIDs",
|
|
14
|
+
"fields": {
|
|
15
|
+
"stepType": "step_type",
|
|
16
|
+
"annotations": "annotations",
|
|
17
|
+
"connectionName": "connection_name",
|
|
18
|
+
"resultAttribute": "result_attribute"
|
|
19
|
+
},
|
|
20
|
+
"branches": [
|
|
21
|
+
{
|
|
22
|
+
"id": "listed",
|
|
23
|
+
"field": "listed",
|
|
24
|
+
"optional": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "providerRejected",
|
|
28
|
+
"field": "provider_rejected",
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "invalidResponse",
|
|
33
|
+
"field": "invalid_response",
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "defect",
|
|
38
|
+
"field": "defect",
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "new_get_item_step",
|
|
45
|
+
"kind": "query",
|
|
46
|
+
"operationId": "getItem",
|
|
47
|
+
"fields": {
|
|
48
|
+
"stepType": "step_type",
|
|
49
|
+
"annotations": "annotations",
|
|
50
|
+
"connectionName": "connection_name",
|
|
51
|
+
"resultAttribute": "result_attribute"
|
|
52
|
+
},
|
|
53
|
+
"branches": [
|
|
54
|
+
{
|
|
55
|
+
"id": "found",
|
|
56
|
+
"field": "found",
|
|
57
|
+
"optional": false
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "notFound",
|
|
61
|
+
"field": "not_found",
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "providerRejected",
|
|
66
|
+
"field": "provider_rejected",
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "invalidResponse",
|
|
71
|
+
"field": "invalid_response",
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "defect",
|
|
76
|
+
"field": "defect",
|
|
77
|
+
"optional": true
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# connectorctl: handwritten facade
|
|
4
|
+
"""Manifest-backed Hacker News scripts for consuming Flow tests."""
|
|
5
|
+
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from dex_connectors.sdk import ConnectionRef, Failure, FailureKind
|
|
9
|
+
from dex_connectors.sdk.connectormock import Case, Query
|
|
10
|
+
|
|
11
|
+
from ..client import (
|
|
12
|
+
CONNECTOR_ID,
|
|
13
|
+
FAILURE_PROVIDER,
|
|
14
|
+
FOUND,
|
|
15
|
+
GET_ITEM_DEFINITION,
|
|
16
|
+
LIST_STORY_IDS_DEFINITION,
|
|
17
|
+
LISTED,
|
|
18
|
+
NOT_FOUND,
|
|
19
|
+
PROVIDER_REJECTED,
|
|
20
|
+
)
|
|
21
|
+
from ..connection import Connection, new_connection_with_operations
|
|
22
|
+
from ..models import GetItemInput, Item, ListStoryIDsInput, StoryIDs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_story_ids_listed(value: StoryIDs, failure: Failure | None = None) -> Case[StoryIDs]:
|
|
26
|
+
"""Script the successful listed route."""
|
|
27
|
+
return Case.branch_result(LISTED, value, failure)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_story_ids_developer_feed() -> Case[StoryIDs]:
|
|
31
|
+
"""Return the manifest's developerFeed answer."""
|
|
32
|
+
return list_story_ids_listed(StoryIDs((42001,), True, datetime(2026, 10, 26, 12, tzinfo=UTC)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_story_ids_feed_rejected() -> Case[StoryIDs]:
|
|
36
|
+
"""Return the manifest's feedRejected answer."""
|
|
37
|
+
return Case.branch_result(
|
|
38
|
+
PROVIDER_REJECTED,
|
|
39
|
+
StoryIDs(),
|
|
40
|
+
Failure(
|
|
41
|
+
FailureKind.PROVIDER_REJECTION,
|
|
42
|
+
FAILURE_PROVIDER,
|
|
43
|
+
"listStoryIDs",
|
|
44
|
+
"Hacker News rejected the feed request.",
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_item_found(value: Item, failure: Failure | None = None) -> Case[Item]:
|
|
50
|
+
"""Script the successful found route."""
|
|
51
|
+
return Case.branch_result(FOUND, value, failure)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_item_compiler_story() -> Case[Item]:
|
|
55
|
+
"""Return the manifest's compilerStory answer."""
|
|
56
|
+
return get_item_found(
|
|
57
|
+
Item(
|
|
58
|
+
id=42001,
|
|
59
|
+
type="story",
|
|
60
|
+
discussion_url="https://news.ycombinator.com/item?id=42001",
|
|
61
|
+
by="compilerdev",
|
|
62
|
+
time=1793016000,
|
|
63
|
+
title="A compiler release",
|
|
64
|
+
url="https://example.com/compiler",
|
|
65
|
+
score=100,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_item_missing() -> Case[Item]:
|
|
71
|
+
"""Return the manifest's itemMissing answer."""
|
|
72
|
+
return Case.branch_result(
|
|
73
|
+
NOT_FOUND,
|
|
74
|
+
Item(),
|
|
75
|
+
Failure(FailureKind.NOT_FOUND, FAILURE_PROVIDER, "getItem", "Hacker News item is deleted or dead."),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_item_rejected() -> Case[Item]:
|
|
80
|
+
"""Return the manifest's itemRejected answer."""
|
|
81
|
+
return Case.branch_result(
|
|
82
|
+
PROVIDER_REJECTED,
|
|
83
|
+
Item(),
|
|
84
|
+
Failure(
|
|
85
|
+
FailureKind.PROVIDER_REJECTION,
|
|
86
|
+
FAILURE_PROVIDER,
|
|
87
|
+
"getItem",
|
|
88
|
+
"Hacker News rejected the item request.",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Mock:
|
|
94
|
+
"""Script Hacker News Queries, defaulting to their manifest answers."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, connection_name: str = "hackernews") -> None:
|
|
97
|
+
self.list_story_ids_script: Query[ListStoryIDsInput, StoryIDs] = Query(LIST_STORY_IDS_DEFINITION)
|
|
98
|
+
self.list_story_ids_script.default(list_story_ids_developer_feed())
|
|
99
|
+
self.get_item_script: Query[GetItemInput, Item] = Query(GET_ITEM_DEFINITION)
|
|
100
|
+
self.get_item_script.default(get_item_compiler_story())
|
|
101
|
+
self._connection = new_connection_with_operations(
|
|
102
|
+
self.list_story_ids_script, self.get_item_script, ConnectionRef(CONNECTOR_ID, connection_name)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def connection(self) -> Connection:
|
|
106
|
+
"""Return this mock's named connection for Step registration."""
|
|
107
|
+
return self._connection
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"Mock",
|
|
112
|
+
"list_story_ids_listed",
|
|
113
|
+
"list_story_ids_developer_feed",
|
|
114
|
+
"list_story_ids_feed_rejected",
|
|
115
|
+
"get_item_found",
|
|
116
|
+
"get_item_compiler_story",
|
|
117
|
+
"get_item_missing",
|
|
118
|
+
"get_item_rejected",
|
|
119
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Typed Hacker News feed snapshots and items."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ._generated.models import GetItemInput as GetItemInput
|
|
8
|
+
from ._generated.models import ListStoryIDsInput as ListStoryIDsInput
|
|
9
|
+
from ._generated.models import StoryIDs as StoryIDs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Item:
|
|
14
|
+
"""Public story, comment, job, poll, or poll option; text_html is untrusted provider HTML."""
|
|
15
|
+
|
|
16
|
+
id: int = 0
|
|
17
|
+
type: str = ""
|
|
18
|
+
discussion_url: str = field(default="", metadata={"dex_json_name": "discussionUrl"})
|
|
19
|
+
by: str = field(default="", metadata={"dex_json_omit_default": True})
|
|
20
|
+
time: int = field(default=0, metadata={"dex_json_omit_default": True})
|
|
21
|
+
title: str = field(default="", metadata={"dex_json_omit_default": True})
|
|
22
|
+
text_html: str = field(default="", metadata={"dex_json_name": "text", "dex_json_omit_default": True})
|
|
23
|
+
url: str = field(default="", metadata={"dex_json_omit_default": True})
|
|
24
|
+
score: int = field(default=0, metadata={"dex_json_omit_default": True})
|
|
25
|
+
descendants: int = field(default=0, metadata={"dex_json_omit_default": True})
|
|
26
|
+
parent: int = field(default=0, metadata={"dex_json_omit_default": True})
|
|
27
|
+
kids: tuple[int, ...] = field(default=(), metadata={"dex_json_omit_default": True})
|
|
28
|
+
deleted: bool = field(default=False, metadata={"dex_json_omit_default": True})
|
|
29
|
+
dead: bool = field(default=False, metadata={"dex_json_omit_default": True})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ListStoryIDsInput.__doc__ = (
|
|
33
|
+
"Feed (top, best, new, ask, or show; blank means top) and limit (zero means 50, at most 500)."
|
|
34
|
+
)
|
|
35
|
+
StoryIDs.__doc__ = "Item IDs in provider order, whether the feed held more, and when the snapshot was read."
|
|
36
|
+
GetItemInput.__doc__ = "Positive Hacker News item ID."
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Public operation-specific Step factories from connector.yaml."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable as Callable
|
|
6
|
+
from collections.abc import Mapping as Mapping
|
|
7
|
+
from typing import TypeVar as TypeVar
|
|
8
|
+
|
|
9
|
+
import dex as dex
|
|
10
|
+
from dex_connectors.sdk import QueryResult as QueryResult
|
|
11
|
+
from dex_connectors.sdk import QueryStep as QueryStep
|
|
12
|
+
from dex_connectors.sdk import QueryStepConfig as QueryStepConfig
|
|
13
|
+
from dex_connectors.sdk import StepAnnotations as StepAnnotations
|
|
14
|
+
|
|
15
|
+
from ._generated.steps import (
|
|
16
|
+
StepInput as StepInput,
|
|
17
|
+
)
|
|
18
|
+
from ._generated.steps import (
|
|
19
|
+
new_get_item_step as new_get_item_step,
|
|
20
|
+
)
|
|
21
|
+
from ._generated.steps import (
|
|
22
|
+
new_list_story_ids_step as new_list_story_ids_step,
|
|
23
|
+
)
|
|
24
|
+
from .connection import Connection as Connection
|
|
25
|
+
from .models import GetItemInput as GetItemInput
|
|
26
|
+
from .models import Item as Item
|
|
27
|
+
from .models import ListStoryIDsInput as ListStoryIDsInput
|
|
28
|
+
from .models import StoryIDs as StoryIDs
|
|
29
|
+
|
|
30
|
+
BranchTarget = TypeVar("BranchTarget")
|
|
31
|
+
|
|
32
|
+
new_list_story_ids_step.__doc__ = (
|
|
33
|
+
"Create the typed feed snapshot Step; unwired optional outcomes fail the Flow."
|
|
34
|
+
)
|
|
35
|
+
new_get_item_step.__doc__ = "Create the typed item Step; unwired optional outcomes fail the Flow."
|