prefect-github 0.3.2__tar.gz → 0.4.1__tar.gz

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 (51) hide show
  1. {prefect_github-0.3.2/prefect_github.egg-info → prefect_github-0.4.1}/PKG-INFO +2 -3
  2. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/_version.py +3 -3
  3. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/graphql.py +38 -8
  4. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/repository.py +62 -6
  5. {prefect_github-0.3.2 → prefect_github-0.4.1/prefect_github.egg-info}/PKG-INFO +2 -3
  6. {prefect_github-0.3.2 → prefect_github-0.4.1}/pyproject.toml +1 -2
  7. prefect_github-0.4.1/tests/test_graphql.py +117 -0
  8. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/test_repository.py +119 -3
  9. prefect_github-0.3.2/tests/test_graphql.py +0 -51
  10. {prefect_github-0.3.2 → prefect_github-0.4.1}/LICENSE +0 -0
  11. {prefect_github-0.3.2 → prefect_github-0.4.1}/MANIFEST.in +0 -0
  12. {prefect_github-0.3.2 → prefect_github-0.4.1}/README.md +0 -0
  13. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/__init__.py +0 -0
  14. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/add_comment.json +0 -0
  15. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/add_pull_request_review.json +0 -0
  16. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/add_reaction.json +0 -0
  17. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/add_star.json +0 -0
  18. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/close_issue.json +0 -0
  19. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/close_pull_request.json +0 -0
  20. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/create_issue.json +0 -0
  21. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/create_pull_request.json +0 -0
  22. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/remove_reaction.json +0 -0
  23. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/remove_star.json +0 -0
  24. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/mutation/request_reviews.json +0 -0
  25. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/query/organization.json +0 -0
  26. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/query/repository.json +0 -0
  27. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/query/repository_owner.json +0 -0
  28. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/query/user.json +0 -0
  29. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/configs/query/viewer.json +0 -0
  30. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/credentials.py +0 -0
  31. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/exceptions.py +0 -0
  32. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/mutations.py +0 -0
  33. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/organization.py +0 -0
  34. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/repository_owner.py +0 -0
  35. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/schemas/__init__.py +0 -0
  36. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/schemas/graphql_schema.json +0 -0
  37. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/schemas/graphql_schema.py +0 -0
  38. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/user.py +0 -0
  39. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/utils.py +0 -0
  40. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github/viewer.py +0 -0
  41. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github.egg-info/SOURCES.txt +0 -0
  42. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github.egg-info/dependency_links.txt +0 -0
  43. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github.egg-info/entry_points.txt +0 -0
  44. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github.egg-info/requires.txt +0 -0
  45. {prefect_github-0.3.2 → prefect_github-0.4.1}/prefect_github.egg-info/top_level.txt +0 -0
  46. {prefect_github-0.3.2 → prefect_github-0.4.1}/setup.cfg +0 -0
  47. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/conftest.py +0 -0
  48. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/test_block_standards.py +0 -0
  49. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/test_credentials.py +0 -0
  50. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/test_utils.py +0 -0
  51. {prefect_github-0.3.2 → prefect_github-0.4.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefect-github
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: Prefect integrations interacting with GitHub
5
5
  Author-email: "Prefect Technologies, Inc." <help@prefect.io>
6
6
  License: Apache License 2.0
@@ -11,13 +11,12 @@ Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: System Administrators
12
12
  Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Topic :: Software Development :: Libraries
20
- Requires-Python: >=3.9
19
+ Requires-Python: >=3.10
21
20
  Description-Content-Type: text/markdown
22
21
  License-File: LICENSE
23
22
  Requires-Dist: sgqlc>=15.0
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.2'
32
- __version_tuple__ = version_tuple = (0, 3, 2)
31
+ __version__ = version = '0.4.1'
32
+ __version_tuple__ = version_tuple = (0, 4, 1)
33
33
 
34
- __commit_id__ = commit_id = 'g8a37e7b1b'
34
+ __commit_id__ = commit_id = 'gd77914141'
@@ -13,11 +13,28 @@ from anyio import to_thread
13
13
  from sgqlc.operation import Operation, Selection
14
14
 
15
15
  from prefect import task
16
- from prefect.utilities.asyncutils import sync_compatible
16
+ from prefect._internal.compatibility.async_dispatch import async_dispatch
17
17
  from prefect_github import GitHubCredentials
18
18
  from prefect_github.utils import camel_to_snake_case
19
19
 
20
20
 
21
+ def _execute_graphql_op_sync(
22
+ op: Union[Operation, str],
23
+ github_credentials: GitHubCredentials,
24
+ error_key: str = "errors",
25
+ **vars,
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Sync helper function for executing GraphQL operations.
29
+ """
30
+ endpoint = github_credentials.get_client()
31
+ result = endpoint(op, vars)
32
+ if error_key in result:
33
+ errors = pformat(result[error_key])
34
+ raise RuntimeError(f"Error encountered:\n{errors}")
35
+ return result["data"]
36
+
37
+
21
38
  async def _execute_graphql_op(
22
39
  op: Union[Operation, str],
23
40
  github_credentials: GitHubCredentials,
@@ -25,7 +42,7 @@ async def _execute_graphql_op(
25
42
  **vars,
26
43
  ) -> Dict[str, Any]:
27
44
  """
28
- Helper function for executing GraphQL operations.
45
+ Async helper function for executing GraphQL operations.
29
46
  """
30
47
  endpoint = github_credentials.get_client()
31
48
  partial_endpoint = partial(endpoint, op, vars)
@@ -62,8 +79,24 @@ def _subset_return_fields(
62
79
 
63
80
 
64
81
  @task
65
- @sync_compatible
66
- async def execute_graphql(
82
+ async def aexecute_graphql(
83
+ op: Union[Operation, str],
84
+ github_credentials: GitHubCredentials,
85
+ error_key: str = "errors",
86
+ **vars,
87
+ ) -> Dict[str, Any]:
88
+ """
89
+ Async version of execute_graphql. See execute_graphql for full documentation.
90
+ """
91
+ result = await _execute_graphql_op(
92
+ op, github_credentials, error_key=error_key, **vars
93
+ )
94
+ return result
95
+
96
+
97
+ @task
98
+ @async_dispatch(aexecute_graphql)
99
+ def execute_graphql(
67
100
  op: Union[Operation, str],
68
101
  github_credentials: GitHubCredentials,
69
102
  error_key: str = "errors",
@@ -142,7 +175,4 @@ async def execute_graphql(
142
175
  example_execute_graphql_flow()
143
176
  ```
144
177
  """
145
- result = await _execute_graphql_op(
146
- op, github_credentials, error_key=error_key, **vars
147
- )
148
- return result
178
+ return _execute_graphql_op_sync(op, github_credentials, error_key=error_key, **vars)
@@ -8,8 +8,10 @@ GitHub query_repository* tasks and the GitHub storage block.
8
8
  # is outdated, rerun scripts/generate.py.
9
9
 
10
10
  import io
11
+ import re
11
12
  import shlex
12
13
  import shutil
14
+ import subprocess
13
15
  from datetime import datetime
14
16
  from pathlib import Path
15
17
  from tempfile import TemporaryDirectory
@@ -20,8 +22,9 @@ from pydantic import Field
20
22
  from sgqlc.operation import Operation
21
23
 
22
24
  from prefect import task
25
+ from prefect._internal.compatibility.async_dispatch import async_dispatch
26
+ from prefect._internal.urls import strip_auth_from_url
23
27
  from prefect.filesystems import ReadableDeploymentStorage
24
- from prefect.utilities.asyncutils import sync_compatible
25
28
  from prefect.utilities.processutils import run_process
26
29
  from prefect_github import GitHubCredentials
27
30
  from prefect_github.graphql import _execute_graphql_op, _subset_return_fields
@@ -31,6 +34,15 @@ from prefect_github.utils import initialize_return_fields_defaults, strip_kwargs
31
34
  config_path = Path(__file__).parent.resolve() / "configs" / "query" / "repository.json"
32
35
  return_fields_defaults = initialize_return_fields_defaults(config_path)
33
36
 
37
+ _URL_PATTERN = re.compile(r"https?://[^\s'\"<>]+")
38
+
39
+
40
+ def _sanitize_git_error(message: str) -> str:
41
+ def _replace(match: re.Match[str]) -> str:
42
+ return strip_auth_from_url(match.group(0))
43
+
44
+ return _URL_PATTERN.sub(_replace, message)
45
+
34
46
 
35
47
  class GitHubRepository(ReadableDeploymentStorage):
36
48
  """
@@ -96,14 +108,13 @@ class GitHubRepository(ReadableDeploymentStorage):
96
108
 
97
109
  return str(content_source), str(content_destination)
98
110
 
99
- @sync_compatible
100
- async def get_directory(
111
+ async def aget_directory(
101
112
  self, from_path: Optional[str] = None, local_path: Optional[str] = None
102
113
  ) -> None:
103
114
  """
104
115
  Clones a GitHub project specified in `from_path` to the provided `local_path`;
105
116
  defaults to cloning the repository reference configured on the Block to the
106
- present working directory.
117
+ present working directory. Async version.
107
118
 
108
119
  Args:
109
120
  from_path: If provided, interpreted as a subdirectory of the underlying
@@ -130,14 +141,59 @@ class GitHubRepository(ReadableDeploymentStorage):
130
141
  process = await run_process(cmd, stream_output=(out_stream, err_stream))
131
142
  if process.returncode != 0:
132
143
  err_stream.seek(0)
133
- raise RuntimeError(f"Failed to pull from remote:\n {err_stream.read()}")
144
+ sanitized_error = _sanitize_git_error(err_stream.read())
145
+ raise RuntimeError(f"Failed to pull from remote:\n {sanitized_error}")
146
+
147
+ content_source, content_destination = self._get_paths(
148
+ dst_dir=local_path, src_dir=tmp_path_str, sub_directory=from_path
149
+ )
150
+
151
+ shutil.copytree(
152
+ src=content_source,
153
+ dst=content_destination,
154
+ dirs_exist_ok=True,
155
+ symlinks=True,
156
+ )
157
+
158
+ @async_dispatch(aget_directory)
159
+ def get_directory(
160
+ self, from_path: Optional[str] = None, local_path: Optional[str] = None
161
+ ) -> None:
162
+ """
163
+ Clones a GitHub project specified in `from_path` to the provided `local_path`;
164
+ defaults to cloning the repository reference configured on the Block to the
165
+ present working directory.
166
+
167
+ Args:
168
+ from_path: If provided, interpreted as a subdirectory of the underlying
169
+ repository that will be copied to the provided local path.
170
+ local_path: A local path to clone to; defaults to present working directory.
171
+ """
172
+ cmd = f"git clone {self._create_repo_url()}"
173
+ if self.reference:
174
+ cmd += f" -b {self.reference}"
175
+
176
+ cmd += " --depth 1"
177
+
178
+ with TemporaryDirectory(suffix="prefect") as tmp_dir:
179
+ tmp_path_str = tmp_dir
180
+ cmd += f' "{tmp_path_str}"'
181
+ cmd = shlex.split(cmd)
182
+
183
+ result = subprocess.run(cmd, capture_output=True, text=True)
184
+ if result.returncode != 0:
185
+ sanitized_error = _sanitize_git_error(result.stderr)
186
+ raise RuntimeError(f"Failed to pull from remote:\n {sanitized_error}")
134
187
 
135
188
  content_source, content_destination = self._get_paths(
136
189
  dst_dir=local_path, src_dir=tmp_path_str, sub_directory=from_path
137
190
  )
138
191
 
139
192
  shutil.copytree(
140
- src=content_source, dst=content_destination, dirs_exist_ok=True
193
+ src=content_source,
194
+ dst=content_destination,
195
+ dirs_exist_ok=True,
196
+ symlinks=True,
141
197
  )
142
198
 
143
199
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefect-github
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: Prefect integrations interacting with GitHub
5
5
  Author-email: "Prefect Technologies, Inc." <help@prefect.io>
6
6
  License: Apache License 2.0
@@ -11,13 +11,12 @@ Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: System Administrators
12
12
  Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Topic :: Software Development :: Libraries
20
- Requires-Python: >=3.9
19
+ Requires-Python: >=3.10
21
20
  Description-Content-Type: text/markdown
22
21
  License-File: LICENSE
23
22
  Requires-Dist: sgqlc>=15.0
@@ -8,7 +8,7 @@ dependencies = ["sgqlc>=15.0", "prefect>=3.0.0"]
8
8
  dynamic = ["version"]
9
9
  description = "Prefect integrations interacting with GitHub"
10
10
  readme = "README.md"
11
- requires-python = ">=3.9"
11
+ requires-python = ">=3.10"
12
12
  license = { text = "Apache License 2.0" }
13
13
  keywords = ["prefect"]
14
14
  authors = [{ name = "Prefect Technologies, Inc.", email = "help@prefect.io" }]
@@ -18,7 +18,6 @@ classifiers = [
18
18
  "Intended Audience :: System Administrators",
19
19
  "License :: OSI Approved :: Apache Software License",
20
20
  "Programming Language :: Python :: 3 :: Only",
21
- "Programming Language :: Python :: 3.9",
22
21
  "Programming Language :: Python :: 3.10",
23
22
  "Programming Language :: Python :: 3.11",
24
23
  "Programming Language :: Python :: 3.12",
@@ -0,0 +1,117 @@
1
+ from typing import Coroutine
2
+
3
+ import pytest
4
+ from prefect_github.graphql import (
5
+ _subset_return_fields,
6
+ aexecute_graphql,
7
+ execute_graphql,
8
+ )
9
+ from prefect_github.schemas import graphql_schema
10
+ from sgqlc.operation import Operation, Selector
11
+
12
+ from prefect import flow
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ "return_fields_key", ["return_fields", "return_fields_defaults"]
17
+ )
18
+ def test_subset_return_fields(return_fields_key):
19
+ op = Operation(graphql_schema.Query)
20
+ op_stack = graphql_schema.Query.__field_names__[:1]
21
+ op_selection = getattr(op, op_stack[0])
22
+ subset_kwargs = {"return_fields": [], "return_fields_defaults": {}}
23
+ return_fields = ["id"]
24
+ if return_fields_key == "return_fields":
25
+ subset_kwargs[return_fields_key] = return_fields
26
+ else:
27
+ subset_kwargs[return_fields_key] = {op_stack: return_fields}
28
+ op_selection = _subset_return_fields(op_selection, op_stack, **subset_kwargs)
29
+ assert isinstance(op_selection, Selector)
30
+
31
+
32
+ class MockCredentials:
33
+ def __init__(self, error_key=None):
34
+ self.result = (
35
+ {error_key: "Errors encountered:"} if error_key else {"data": "success"}
36
+ )
37
+
38
+ def get_endpoint(self):
39
+ return lambda op, vars: self.result
40
+
41
+ def get_client(self):
42
+ return lambda op, vars: self.result
43
+
44
+
45
+ @pytest.mark.parametrize("error_key", ["errors", False])
46
+ def test_execute_graphql(error_key):
47
+ mock_credentials = MockCredentials(error_key=error_key)
48
+
49
+ @flow
50
+ def test_flow():
51
+ return execute_graphql("op", mock_credentials)
52
+
53
+ if error_key:
54
+ with pytest.raises(RuntimeError, match="Errors encountered:"):
55
+ test_flow()
56
+ else:
57
+ assert test_flow() == "success"
58
+
59
+
60
+ class TestExecuteGraphqlAsyncDispatch:
61
+ """Tests for execute_graphql migrated from @sync_compatible to @async_dispatch.
62
+
63
+ These tests verify the critical behavior from issue #15008 where
64
+ @sync_compatible would incorrectly return coroutines in sync context.
65
+ """
66
+
67
+ def test_execute_graphql_sync_context_returns_value_not_coroutine(self):
68
+ """execute_graphql must return value (not coroutine) in sync context.
69
+
70
+ This is the critical regression test for issues #14712 and #14625.
71
+ """
72
+ mock_credentials = MockCredentials(error_key=False)
73
+
74
+ @flow
75
+ def test_flow():
76
+ result = execute_graphql("op", mock_credentials)
77
+ # the result inside the flow should be the actual value, not a coroutine
78
+ assert not isinstance(result, Coroutine), "sync context returned coroutine"
79
+ return result
80
+
81
+ assert test_flow() == "success"
82
+
83
+ async def test_execute_graphql_async_context_works(self):
84
+ """execute_graphql should work correctly in async context."""
85
+ mock_credentials = MockCredentials(error_key=False)
86
+
87
+ @flow
88
+ async def test_flow():
89
+ # when decorated with @task, the task machinery handles execution
90
+ # so we get a result directly, not a coroutine
91
+ result = execute_graphql("op", mock_credentials)
92
+ return result
93
+
94
+ assert await test_flow() == "success"
95
+
96
+ def test_aexecute_graphql_is_exported(self):
97
+ """aexecute_graphql should be available for direct async usage."""
98
+ # just verify it's importable and is a task
99
+ assert callable(aexecute_graphql)
100
+
101
+ def test_task_identity_preserved(self):
102
+ """Both execute_graphql and aexecute_graphql should have Task identity.
103
+
104
+ This verifies decorator order is correct (@task must be outermost).
105
+ See PR #20300 for the dbt decorator ordering fix.
106
+ """
107
+ # .with_options() is only available on proper Task objects
108
+ assert hasattr(execute_graphql, "with_options"), (
109
+ "execute_graphql missing .with_options() - check decorator order"
110
+ )
111
+ assert hasattr(aexecute_graphql, "with_options"), (
112
+ "aexecute_graphql missing .with_options() - check decorator order"
113
+ )
114
+
115
+ # verify we can actually call with_options
116
+ configured = execute_graphql.with_options(retries=3)
117
+ assert configured is not None
@@ -1,15 +1,14 @@
1
1
  import os
2
2
  from pathlib import Path
3
3
  from tempfile import TemporaryDirectory
4
- from typing import Tuple
4
+ from typing import Coroutine, Tuple
5
+ from unittest.mock import AsyncMock
5
6
 
6
7
  import prefect_github
7
8
  import pytest
8
9
  from prefect_github import GitHubCredentials
9
10
  from prefect_github.repository import GitHubRepository
10
11
 
11
- from prefect.testing.utilities import AsyncMock
12
-
13
12
 
14
13
  class TestGitHubRepository:
15
14
  async def test_subprocess_errors_are_surfaced(self):
@@ -73,6 +72,34 @@ class TestGitHubRepository:
73
72
  in " ".join(mock.await_args[0][0])
74
73
  )
75
74
 
75
+ async def test_get_directory_redacts_token_in_error(self, monkeypatch):
76
+ """Ensure GitHub access tokens are not surfaced in git error messages."""
77
+
78
+ class p:
79
+ returncode = 1
80
+
81
+ async def mock(cmd, stream_output=None, **kwargs):
82
+ if stream_output:
83
+ _, err_stream = stream_output
84
+ err_stream.write(
85
+ "fatal: Authentication failed for "
86
+ "'https://XYZ@github.com/PrefectHQ/prefect.git/'"
87
+ )
88
+ return p()
89
+
90
+ monkeypatch.setattr(prefect_github.repository, "run_process", mock)
91
+ credential = GitHubCredentials(token="XYZ")
92
+ g = GitHubRepository(
93
+ repository_url="https://github.com/PrefectHQ/prefect.git",
94
+ credentials=credential,
95
+ )
96
+ with pytest.raises(RuntimeError) as excinfo:
97
+ await g.get_directory()
98
+
99
+ message = str(excinfo.value)
100
+ assert "XYZ" not in message
101
+ assert "https://github.com/PrefectHQ/prefect.git" in message
102
+
76
103
  def setup_test_directory(
77
104
  self, tmp_src: str, sub_dir: str = "puppy"
78
105
  ) -> Tuple[str, str]:
@@ -189,3 +216,92 @@ class TestGitHubRepository:
189
216
 
190
217
  assert set(os.listdir(tmp_dst)) == set([sub_dir_name])
191
218
  assert set(os.listdir(Path(tmp_dst) / sub_dir_name)) == child_contents
219
+
220
+ async def test_get_directory_preserves_symlinks(self, monkeypatch):
221
+ """Test that get_directory preserves symlinks instead of following them.
222
+
223
+ This verifies the fix for issue #7868 where symlinks were being resolved
224
+ and their target files copied, potentially exposing sensitive files.
225
+ """
226
+
227
+ class p:
228
+ returncode = 0
229
+
230
+ mock = AsyncMock(return_value=p())
231
+ monkeypatch.setattr(prefect_github.repository, "run_process", mock)
232
+
233
+ with TemporaryDirectory() as tmp_src:
234
+ # Create a real file
235
+ real_file = Path(tmp_src) / "real_file.txt"
236
+ real_file.write_text("real content")
237
+
238
+ # Create a symlink
239
+ symlink_file = Path(tmp_src) / "link_file.txt"
240
+ symlink_file.symlink_to(real_file)
241
+
242
+ self.MockTmpDir.dir = tmp_src
243
+
244
+ with TemporaryDirectory() as tmp_dst:
245
+ monkeypatch.setattr(
246
+ prefect_github.repository,
247
+ "TemporaryDirectory",
248
+ self.MockTmpDir,
249
+ )
250
+
251
+ g = GitHubRepository(
252
+ repository_url="https://github.com/PrefectHQ/prefect.git",
253
+ )
254
+ await g.get_directory(local_path=tmp_dst)
255
+
256
+ # Verify the symlink is preserved as a symlink
257
+ copied_symlink = Path(tmp_dst) / "link_file.txt"
258
+ assert copied_symlink.is_symlink(), (
259
+ "Symlink should be preserved as a symlink"
260
+ )
261
+
262
+ # Verify the real file is copied
263
+ copied_real = Path(tmp_dst) / "real_file.txt"
264
+ assert copied_real.exists()
265
+ assert copied_real.read_text() == "real content"
266
+
267
+
268
+ class TestGitHubRepositoryAsyncDispatch:
269
+ """Tests for GitHubRepository.get_directory migrated from @sync_compatible to @async_dispatch.
270
+
271
+ These tests verify the critical behavior from issue #15008 where
272
+ @sync_compatible would incorrectly return coroutines in sync context.
273
+ """
274
+
275
+ def test_get_directory_sync_context_returns_none_not_coroutine(self, monkeypatch):
276
+ """get_directory must return None (not coroutine) in sync context.
277
+
278
+ This is the critical regression test for issues #14712 and #14625.
279
+ """
280
+ import subprocess
281
+ from unittest.mock import MagicMock
282
+
283
+ mock_result = MagicMock()
284
+ mock_result.returncode = 0
285
+ mock_result.stderr = ""
286
+ monkeypatch.setattr(subprocess, "run", MagicMock(return_value=mock_result))
287
+
288
+ g = GitHubRepository(repository_url="prefect")
289
+ result = g.get_directory()
290
+
291
+ assert not isinstance(result, Coroutine), "sync context returned coroutine"
292
+ assert result is None
293
+
294
+ async def test_get_directory_async_context_returns_coroutine(self, monkeypatch):
295
+ """get_directory should dispatch to async and return coroutine in async context."""
296
+
297
+ class p:
298
+ returncode = 0
299
+
300
+ mock = AsyncMock(return_value=p())
301
+ monkeypatch.setattr(prefect_github.repository, "run_process", mock)
302
+
303
+ g = GitHubRepository(repository_url="prefect")
304
+ result = g.get_directory()
305
+
306
+ assert isinstance(result, Coroutine)
307
+ await result # should complete without error
@@ -1,51 +0,0 @@
1
- import pytest
2
- from prefect_github.graphql import _subset_return_fields, execute_graphql
3
- from prefect_github.schemas import graphql_schema
4
- from sgqlc.operation import Operation, Selector
5
-
6
- from prefect import flow
7
-
8
-
9
- @pytest.mark.parametrize(
10
- "return_fields_key", ["return_fields", "return_fields_defaults"]
11
- )
12
- def test_subset_return_fields(return_fields_key):
13
- op = Operation(graphql_schema.Query)
14
- op_stack = graphql_schema.Query.__field_names__[:1]
15
- op_selection = getattr(op, op_stack[0])
16
- subset_kwargs = {"return_fields": [], "return_fields_defaults": {}}
17
- return_fields = ["id"]
18
- if return_fields_key == "return_fields":
19
- subset_kwargs[return_fields_key] = return_fields
20
- else:
21
- subset_kwargs[return_fields_key] = {op_stack: return_fields}
22
- op_selection = _subset_return_fields(op_selection, op_stack, **subset_kwargs)
23
- assert isinstance(op_selection, Selector)
24
-
25
-
26
- class MockCredentials:
27
- def __init__(self, error_key=None):
28
- self.result = (
29
- {error_key: "Errors encountered:"} if error_key else {"data": "success"}
30
- )
31
-
32
- def get_endpoint(self):
33
- return lambda op, vars: self.result
34
-
35
- def get_client(self):
36
- return lambda op, vars: self.result
37
-
38
-
39
- @pytest.mark.parametrize("error_key", ["errors", False])
40
- def test_execute_graphql(error_key):
41
- mock_credentials = MockCredentials(error_key=error_key)
42
-
43
- @flow
44
- def test_flow():
45
- return execute_graphql("op", mock_credentials)
46
-
47
- if error_key:
48
- with pytest.raises(RuntimeError, match="Errors encountered:"):
49
- test_flow()
50
- else:
51
- assert test_flow() == "success"
File without changes
File without changes
File without changes