Skip to content

Commit b5b0e15

Browse files
authored
feat: support actor_path on Actor version create and update (#1187)
1 parent 84427ad commit b5b0e15

4 files changed

Lines changed: 279 additions & 0 deletions

File tree

‎src/apify_client/_resource_clients/actor_version.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def update(
7676
git_repo_url: str | None = None,
7777
tarball_url: str | None = None,
7878
github_gist_url: str | None = None,
79+
actor_path: str | None = None,
7980
timeout: Timeout = 'short',
8081
) -> Version:
8182
"""Update the Actor version with specified fields.
@@ -97,6 +98,12 @@ def update(
9798
Required when `source_type` is `'TARBALL'`.
9899
github_gist_url: The URL of a GitHub Gist from which the source will be downloaded.
99100
Required when `source_type` is `'GITHUB_GIST'`.
101+
actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'`
102+
versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters,
103+
with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends
104+
`source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it
105+
again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To
106+
remove it without a new upload, pass the current `source_type` again.
100107
timeout: Timeout for the API HTTP request.
101108
102109
Returns:
@@ -111,6 +118,7 @@ def update(
111118
git_repo_url=git_repo_url,
112119
tarball_url=tarball_url,
113120
github_gist_url=github_gist_url,
121+
actor_path=actor_path,
114122
)
115123
result = self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True))
116124
return VersionResponse.model_validate(result).data
@@ -192,6 +200,7 @@ async def update(
192200
git_repo_url: str | None = None,
193201
tarball_url: str | None = None,
194202
github_gist_url: str | None = None,
203+
actor_path: str | None = None,
195204
timeout: Timeout = 'short',
196205
) -> Version:
197206
"""Update the Actor version with specified fields.
@@ -213,6 +222,12 @@ async def update(
213222
Required when `source_type` is `'TARBALL'`.
214223
github_gist_url: The URL of a GitHub Gist from which the source will be downloaded.
215224
Required when `source_type` is `'GITHUB_GIST'`.
225+
actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'`
226+
versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters,
227+
with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends
228+
`source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it
229+
again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To
230+
remove it without a new upload, pass the current `source_type` again.
216231
timeout: Timeout for the API HTTP request.
217232
218233
Returns:
@@ -227,6 +242,7 @@ async def update(
227242
git_repo_url=git_repo_url,
228243
tarball_url=tarball_url,
229244
github_gist_url=github_gist_url,
245+
actor_path=actor_path,
230246
)
231247
result = await self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True))
232248
return VersionResponse.model_validate(result).data

‎src/apify_client/_resource_clients/actor_version_collection.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def create(
9090
git_repo_url: str | None = None,
9191
tarball_url: str | None = None,
9292
github_gist_url: str | None = None,
93+
actor_path: str | None = None,
9394
timeout: Timeout = 'short',
9495
) -> Version:
9596
"""Create a new Actor version.
@@ -112,6 +113,10 @@ def create(
112113
Required when `source_type` is `'TARBALL'`.
113114
github_gist_url: The URL of a GitHub Gist from which the source will be downloaded.
114115
Required when `source_type` is `'GITHUB_GIST'`.
116+
actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'`
117+
versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters,
118+
with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the
119+
uploaded source is the Actor's directory.
115120
timeout: Timeout for the API HTTP request.
116121
117122
Returns:
@@ -127,6 +132,7 @@ def create(
127132
git_repo_url=git_repo_url,
128133
tarball_url=tarball_url,
129134
github_gist_url=github_gist_url,
135+
actor_path=actor_path,
130136
)
131137
result = self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True))
132138
return VersionResponse.model_validate(result).data
@@ -196,6 +202,7 @@ async def create(
196202
git_repo_url: str | None = None,
197203
tarball_url: str | None = None,
198204
github_gist_url: str | None = None,
205+
actor_path: str | None = None,
199206
timeout: Timeout = 'short',
200207
) -> Version:
201208
"""Create a new Actor version.
@@ -218,6 +225,10 @@ async def create(
218225
Required when `source_type` is `'TARBALL'`.
219226
github_gist_url: The URL of a GitHub Gist from which the source will be downloaded.
220227
Required when `source_type` is `'GITHUB_GIST'`.
228+
actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'`
229+
versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters,
230+
with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the
231+
uploaded source is the Actor's directory.
221232
timeout: Timeout for the API HTTP request.
222233
223234
Returns:
@@ -233,6 +244,7 @@ async def create(
233244
git_repo_url=git_repo_url,
234245
tarball_url=tarball_url,
235246
github_gist_url=github_gist_url,
247+
actor_path=actor_path,
236248
)
237249
result = await self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True))
238250
return VersionResponse.model_validate(result).data

‎tests/integration/test_actor_version.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,72 @@ async def test_actor_version_update(client: ApifyClient | ApifyClientAsync) -> N
151151
await maybe_await(actor_client.delete())
152152

153153

154+
async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) -> None:
155+
"""Test that `actor_path` is stored, kept by other updates, and removed by an upload or a `source_type` update."""
156+
actor_name = get_random_resource_name('actor')
157+
actor_path = 'actors/my-actor'
158+
159+
actor = await maybe_await(client.actors().create(name=actor_name))
160+
assert isinstance(actor, ActorResource)
161+
actor_client = client.actor(actor.id)
162+
version_client = actor_client.version('0.1')
163+
164+
try:
165+
# Create a version from a monorepo upload, with the Actor in a subdirectory
166+
created_version = await maybe_await(
167+
actor_client.versions().create(
168+
version_number='0.1',
169+
source_type='SOURCE_FILES',
170+
source_files=[
171+
{
172+
'name': f'{actor_path}/main.js',
173+
'format': 'TEXT',
174+
'content': 'console.log("Hello from a monorepo")',
175+
}
176+
],
177+
actor_path=actor_path,
178+
)
179+
)
180+
assert isinstance(created_version, Version)
181+
assert created_version.actor_path == actor_path
182+
183+
# An update that leaves the source alone keeps the path
184+
await maybe_await(version_client.update(build_tag='beta'))
185+
kept_version = await maybe_await(version_client.get())
186+
assert isinstance(kept_version, Version)
187+
assert kept_version.actor_path == actor_path
188+
189+
# A new upload without `actor_path` removes the path
190+
await maybe_await(
191+
version_client.update(
192+
source_files=[
193+
{
194+
'name': 'main.js',
195+
'format': 'TEXT',
196+
'content': 'console.log("Hello from the root")',
197+
}
198+
],
199+
)
200+
)
201+
cleared_version = await maybe_await(version_client.get())
202+
assert isinstance(cleared_version, Version)
203+
assert cleared_version.actor_path is None
204+
205+
# Setting the path again and then passing the current source type removes it without a new upload
206+
await maybe_await(version_client.update(actor_path=actor_path))
207+
restored_version = await maybe_await(version_client.get())
208+
assert isinstance(restored_version, Version)
209+
assert restored_version.actor_path == actor_path
210+
211+
await maybe_await(version_client.update(source_type='SOURCE_FILES'))
212+
reset_version = await maybe_await(version_client.get())
213+
assert isinstance(reset_version, Version)
214+
assert reset_version.actor_path is None
215+
216+
finally:
217+
await maybe_await(actor_client.delete())
218+
219+
154220
async def test_actor_version_delete(client: ApifyClient | ApifyClientAsync) -> None:
155221
"""Test deleting an Actor version."""
156222
actor_name = get_random_resource_name('actor')
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from typing import TYPE_CHECKING, Any
5+
6+
import pytest
7+
from werkzeug import Request, Response
8+
9+
if TYPE_CHECKING:
10+
from collections.abc import Callable
11+
12+
from pytest_httpserver import HTTPServer
13+
14+
from apify_client import ApifyClient, ApifyClientAsync
15+
16+
pytestmark = pytest.mark.usefixtures('http_client_classes')
17+
18+
_MOCKED_ACTOR_ID = 'test_actor_id'
19+
_VERSION_NUMBER = '0.1'
20+
_VERSIONS_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}/versions'
21+
_VERSION_PATH = f'{_VERSIONS_PATH}/{_VERSION_NUMBER}'
22+
23+
_ACTOR_PATH = 'actors/my-actor'
24+
_TARBALL_URL = 'https://example.com/monorepo.zip'
25+
26+
_VERSION_RESPONSE = {
27+
'data': {
28+
'versionNumber': _VERSION_NUMBER,
29+
'sourceType': 'TARBALL',
30+
'tarballUrl': _TARBALL_URL,
31+
'buildTag': 'latest',
32+
'envVars': [],
33+
'actorPath': _ACTOR_PATH,
34+
}
35+
}
36+
37+
38+
def capture(captured: list[Request]) -> Callable[[Request], Response]:
39+
def handler(request: Request) -> Response:
40+
captured.append(request)
41+
return Response(json.dumps(_VERSION_RESPONSE), status=200, mimetype='application/json')
42+
43+
return handler
44+
45+
46+
def request_body(request: Request) -> dict[str, Any]:
47+
return json.loads(request.get_data())
48+
49+
50+
def test_create_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
51+
"""`create` sends `actor_path` as `actorPath` and reads it back from the response."""
52+
captured: list[Request] = []
53+
httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured))
54+
55+
versions_client = sync_client.actor(_MOCKED_ACTOR_ID).versions()
56+
version = versions_client.create(
57+
version_number=_VERSION_NUMBER,
58+
source_type='TARBALL',
59+
tarball_url=_TARBALL_URL,
60+
actor_path=_ACTOR_PATH,
61+
)
62+
63+
assert len(captured) == 1
64+
assert request_body(captured[0]) == {
65+
'versionNumber': _VERSION_NUMBER,
66+
'sourceType': 'TARBALL',
67+
'tarballUrl': _TARBALL_URL,
68+
'actorPath': _ACTOR_PATH,
69+
}
70+
assert version.actor_path == _ACTOR_PATH
71+
72+
73+
async def test_create_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
74+
"""Async `create` sends `actor_path` as `actorPath` and reads it back from the response."""
75+
captured: list[Request] = []
76+
httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured))
77+
78+
versions_client = async_client.actor(_MOCKED_ACTOR_ID).versions()
79+
version = await versions_client.create(
80+
version_number=_VERSION_NUMBER,
81+
source_type='TARBALL',
82+
tarball_url=_TARBALL_URL,
83+
actor_path=_ACTOR_PATH,
84+
)
85+
86+
assert len(captured) == 1
87+
assert request_body(captured[0]) == {
88+
'versionNumber': _VERSION_NUMBER,
89+
'sourceType': 'TARBALL',
90+
'tarballUrl': _TARBALL_URL,
91+
'actorPath': _ACTOR_PATH,
92+
}
93+
assert version.actor_path == _ACTOR_PATH
94+
95+
96+
def test_create_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
97+
"""`create` leaves `actorPath` out of the request body when `actor_path` is not passed."""
98+
captured: list[Request] = []
99+
httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured))
100+
101+
versions_client = sync_client.actor(_MOCKED_ACTOR_ID).versions()
102+
versions_client.create(
103+
version_number=_VERSION_NUMBER,
104+
source_type='TARBALL',
105+
tarball_url=_TARBALL_URL,
106+
)
107+
108+
assert len(captured) == 1
109+
assert 'actorPath' not in request_body(captured[0])
110+
111+
112+
async def test_create_omits_actor_path_when_not_passed_async(
113+
httpserver: HTTPServer, async_client: ApifyClientAsync
114+
) -> None:
115+
"""Async `create` leaves `actorPath` out of the request body when `actor_path` is not passed."""
116+
captured: list[Request] = []
117+
httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured))
118+
119+
versions_client = async_client.actor(_MOCKED_ACTOR_ID).versions()
120+
await versions_client.create(
121+
version_number=_VERSION_NUMBER,
122+
source_type='TARBALL',
123+
tarball_url=_TARBALL_URL,
124+
)
125+
126+
assert len(captured) == 1
127+
assert 'actorPath' not in request_body(captured[0])
128+
129+
130+
def test_update_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
131+
"""`update` sends `actor_path` as `actorPath` and reads it back from the response."""
132+
captured: list[Request] = []
133+
httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured))
134+
135+
version_client = sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER)
136+
version = version_client.update(
137+
tarball_url=_TARBALL_URL,
138+
actor_path=_ACTOR_PATH,
139+
)
140+
141+
assert len(captured) == 1
142+
assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH}
143+
assert version.actor_path == _ACTOR_PATH
144+
145+
146+
async def test_update_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None:
147+
"""Async `update` sends `actor_path` as `actorPath` and reads it back from the response."""
148+
captured: list[Request] = []
149+
httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured))
150+
151+
version_client = async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER)
152+
version = await version_client.update(
153+
tarball_url=_TARBALL_URL,
154+
actor_path=_ACTOR_PATH,
155+
)
156+
157+
assert len(captured) == 1
158+
assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH}
159+
assert version.actor_path == _ACTOR_PATH
160+
161+
162+
def test_update_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None:
163+
"""`update` leaves `actorPath` out of the request body when `actor_path` is not passed."""
164+
captured: list[Request] = []
165+
httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured))
166+
167+
version_client = sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER)
168+
version_client.update(tarball_url=_TARBALL_URL)
169+
170+
assert len(captured) == 1
171+
assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL}
172+
173+
174+
async def test_update_omits_actor_path_when_not_passed_async(
175+
httpserver: HTTPServer, async_client: ApifyClientAsync
176+
) -> None:
177+
"""Async `update` leaves `actorPath` out of the request body when `actor_path` is not passed."""
178+
captured: list[Request] = []
179+
httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured))
180+
181+
version_client = async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER)
182+
await version_client.update(tarball_url=_TARBALL_URL)
183+
184+
assert len(captured) == 1
185+
assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL}

0 commit comments

Comments
 (0)