gitcode-api 1.1.3__tar.gz → 1.2.0__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 (37) hide show
  1. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/PKG-INFO +5 -3
  2. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/README.md +4 -2
  3. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/_base_client.py +12 -4
  4. gitcode_api-1.2.0/gitcode_api/_base_resource.py +60 -0
  5. gitcode_api-1.2.0/gitcode_api/_cli_banner.py +31 -0
  6. gitcode_api-1.2.0/gitcode_api/cli.py +366 -0
  7. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/_shared.py +3 -2
  8. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/collaboration.py +4 -4
  9. gitcode_api-1.2.0/gitcode_api/version.txt +1 -0
  10. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/PKG-INFO +5 -3
  11. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/SOURCES.txt +2 -0
  12. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/pyproject.toml +8 -1
  13. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_cli.py +72 -4
  14. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_client.py +8 -14
  15. gitcode_api-1.1.3/gitcode_api/cli.py +0 -255
  16. gitcode_api-1.1.3/gitcode_api/version.txt +0 -1
  17. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/LICENSE +0 -0
  18. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/__init__.py +0 -0
  19. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/__main__.py +0 -0
  20. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/_client.py +0 -0
  21. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/_exceptions.py +0 -0
  22. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/_models.py +0 -0
  23. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/__init__.py +0 -0
  24. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/account.py +0 -0
  25. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/misc.py +0 -0
  26. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api/resources/repositories.py +0 -0
  27. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/dependency_links.txt +0 -0
  28. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/entry_points.txt +0 -0
  29. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/requires.txt +0 -0
  30. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/gitcode_api.egg-info/top_level.txt +0 -0
  31. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/setup.cfg +0 -0
  32. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_base_client.py +0 -0
  33. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_models.py +0 -0
  34. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_resources_account.py +0 -0
  35. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_resources_collaboration.py +0 -0
  36. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_resources_misc.py +0 -0
  37. {gitcode_api-1.1.3 → gitcode_api-1.2.0}/tests/test_resources_repositories.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcode-api
3
- Version: 1.1.3
3
+ Version: 1.2.0
4
4
  Summary: Easy to use Python SDK for the GitCode REST API, community-maintained.
5
5
  Author-email: Hugo Huang <hugo@hugohuang.com>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Dynamic: license-file
29
29
 
30
30
  [![PyPI - Version](https://img.shields.io/pypi/v/gitcode-api?link=https%3A%2F%2Fpypi.org%2Fproject%2Fgitcode-api%2F)](https://pypi.org/project/gitcode-api) [![GitHub Badge](https://img.shields.io/badge/github-repo-blue?logo=github&link=https%3A%2F%2Fgithub.com%2FTrenza1ore%2FGitCode-API)](https://github.com/Trenza1ore/GitCode-API) [![GitCode Badge](https://img.shields.io/badge/gitcode-repo-brown?logo=gitcode&link=https%3A%2F%2Fgitcode.com%2FSushiNinja%2FGitCode-API)](https://gitcode.com/SushiNinja/GitCode-API) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/gitcode-api?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=RED&left_text=downloads)](https://pepy.tech/projects/gitcode-api)
31
31
 
32
- [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md) [![English README](https://img.shields.io/badge/English-README-blue?style=for-the-badge&logo=googledocs&link=README.md)](README.md)
32
+ [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md)
33
33
 
34
34
  `gitcode-api` is a community-maintained Python SDK for the GitCode REST API. It provides easy-to-use synchronous and asynchronous clients, repository-scoped helpers, and lightweight response models so you can work with GitCode from Python without hand-writing raw HTTP requests.
35
35
 
@@ -37,7 +37,7 @@ Dynamic: license-file
37
37
 
38
38
  - Community project for developers who want a practical GitCode Python library.
39
39
  - Sync and async clients with a consistent API surface.
40
- - Resource namespaces such as `client.repos`, `client.pulls`, and `client.users`.
40
+ - Resource groups such as `client.repos`, `client.pulls`, and `client.users`.
41
41
  - Repository defaults via `owner=` and `repo=` on the client.
42
42
  - Sphinx docs plus a mirrored GitCode REST API reference in `docs/`.
43
43
 
@@ -203,6 +203,8 @@ Both `GitCode` and `AsyncGitCode` expose:
203
203
  - `releases`, `tags`, and `webhooks`
204
204
  - `users`, `orgs`, `search`, and `oauth`
205
205
 
206
+ Every resource group inherits a cached `methods` property from the shared resource base: a `tuple` of public callable names in stable SDK order (underscore-segment sort key, not plain A–Z on the full identifier). Private names and the introspection helpers `methods` and `method_signature` are omitted. For example, `client.pulls.methods` helps with discovery or tooling without reading the full manual list. For one method’s parameters and return type, call `client.pulls.method_signature("list_issues")` (a cached string from `inspect.signature`, with `gitcode_api._models.` stripped from annotations).
207
+
206
208
  ## Examples
207
209
 
208
210
  Runnable examples live in `examples/`:
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/gitcode-api?link=https%3A%2F%2Fpypi.org%2Fproject%2Fgitcode-api%2F)](https://pypi.org/project/gitcode-api) [![GitHub Badge](https://img.shields.io/badge/github-repo-blue?logo=github&link=https%3A%2F%2Fgithub.com%2FTrenza1ore%2FGitCode-API)](https://github.com/Trenza1ore/GitCode-API) [![GitCode Badge](https://img.shields.io/badge/gitcode-repo-brown?logo=gitcode&link=https%3A%2F%2Fgitcode.com%2FSushiNinja%2FGitCode-API)](https://gitcode.com/SushiNinja/GitCode-API) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/gitcode-api?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=RED&left_text=downloads)](https://pepy.tech/projects/gitcode-api)
4
4
 
5
- [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md) [![English README](https://img.shields.io/badge/English-README-blue?style=for-the-badge&logo=googledocs&link=README.md)](README.md)
5
+ [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md)
6
6
 
7
7
  `gitcode-api` is a community-maintained Python SDK for the GitCode REST API. It provides easy-to-use synchronous and asynchronous clients, repository-scoped helpers, and lightweight response models so you can work with GitCode from Python without hand-writing raw HTTP requests.
8
8
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  - Community project for developers who want a practical GitCode Python library.
12
12
  - Sync and async clients with a consistent API surface.
13
- - Resource namespaces such as `client.repos`, `client.pulls`, and `client.users`.
13
+ - Resource groups such as `client.repos`, `client.pulls`, and `client.users`.
14
14
  - Repository defaults via `owner=` and `repo=` on the client.
15
15
  - Sphinx docs plus a mirrored GitCode REST API reference in `docs/`.
16
16
 
@@ -176,6 +176,8 @@ Both `GitCode` and `AsyncGitCode` expose:
176
176
  - `releases`, `tags`, and `webhooks`
177
177
  - `users`, `orgs`, `search`, and `oauth`
178
178
 
179
+ Every resource group inherits a cached `methods` property from the shared resource base: a `tuple` of public callable names in stable SDK order (underscore-segment sort key, not plain A–Z on the full identifier). Private names and the introspection helpers `methods` and `method_signature` are omitted. For example, `client.pulls.methods` helps with discovery or tooling without reading the full manual list. For one method’s parameters and return type, call `client.pulls.method_signature("list_issues")` (a cached string from `inspect.signature`, with `gitcode_api._models.` stripped from annotations).
180
+
179
181
  ## Examples
180
182
 
181
183
  Runnable examples live in `examples/`:
@@ -10,6 +10,7 @@ from urllib.parse import quote
10
10
 
11
11
  import httpx
12
12
 
13
+ from ._base_resource import BaseResource
13
14
  from ._exceptions import GitCodeConfigurationError, GitCodeHTTPStatusError
14
15
 
15
16
  DEFAULT_BASE_URL = "https://api.gitcode.com/api/v5"
@@ -181,6 +182,13 @@ class BaseGitCodeClient:
181
182
  except ValueError:
182
183
  return response.text
183
184
 
185
+ def _close_resources(self) -> None:
186
+ """Release resource group caches to allow for garbage collection."""
187
+ for attr in dir(self):
188
+ resource = getattr(self, attr)
189
+ if isinstance(resource, BaseResource):
190
+ resource.method_signature.cache_clear()
191
+
184
192
 
185
193
  class SyncAPIClient(BaseGitCodeClient):
186
194
  """Low-level synchronous HTTP transport used by resource classes.
@@ -207,7 +215,6 @@ class SyncAPIClient(BaseGitCodeClient):
207
215
  ) -> None:
208
216
  """Create or reuse an ``httpx.Client`` for synchronous requests."""
209
217
  super().__init__(api_key=api_key, owner=owner, repo=repo, base_url=base_url, timeout=timeout, decrypt=decrypt)
210
- self._owns_client = http_client is None
211
218
  self._client = http_client or httpx.Client(timeout=self.timeout)
212
219
 
213
220
  def request(
@@ -244,7 +251,8 @@ class SyncAPIClient(BaseGitCodeClient):
244
251
  return self._parse_response(response, raw=raw)
245
252
 
246
253
  def close(self) -> None:
247
- """Close the underlying HTTP client."""
254
+ """Close the underlying HTTP client and clear cache for garbage collection."""
255
+ self._close_resources()
248
256
  self._client.close()
249
257
 
250
258
  def __enter__(self) -> "SyncAPIClient":
@@ -281,7 +289,6 @@ class AsyncAPIClient(BaseGitCodeClient):
281
289
  ) -> None:
282
290
  """Create or reuse an ``httpx.AsyncClient`` for asynchronous requests."""
283
291
  super().__init__(api_key=api_key, owner=owner, repo=repo, base_url=base_url, timeout=timeout, decrypt=decrypt)
284
- self._owns_client = http_client is None
285
292
  self._client = http_client or httpx.AsyncClient(timeout=self.timeout)
286
293
 
287
294
  async def request(
@@ -318,7 +325,8 @@ class AsyncAPIClient(BaseGitCodeClient):
318
325
  return self._parse_response(response, raw=raw)
319
326
 
320
327
  async def close(self) -> None:
321
- """Close the underlying async HTTP client."""
328
+ """Close the underlying async HTTP client and clear cache for garbage collection."""
329
+ self._close_resources()
322
330
  await self._client.aclose()
323
331
 
324
332
  async def __aenter__(self) -> "AsyncAPIClient":
@@ -0,0 +1,60 @@
1
+ """Resource base classe for the GitCode SDK."""
2
+
3
+ from functools import cached_property, lru_cache
4
+
5
+ _UTILITY_METHODS = {"methods", "method_signature"}
6
+ _DATA_MODEL_PATH = "gitcode_api._models."
7
+
8
+
9
+ class BaseResource:
10
+ """Resource group base class"""
11
+
12
+ def __del__(self) -> None:
13
+ """Attempt to clear the lru cache."""
14
+ self.method_signature.cache_clear()
15
+
16
+ @cached_property
17
+ def methods(self) -> tuple[str, ...]:
18
+ """Public callable names on this resource group in stable SDK order.
19
+
20
+ Ordering uses :func:`sorted` with a key derived from each name's
21
+ underscore-separated segments (for example two-part names are ordered
22
+ as if ``second_first``). This is not plain lexicographic order on the
23
+ full method string. Excludes ``methods``, names starting with ``_``,
24
+ and non-callables. The result is cached on first access.
25
+
26
+ :returns: Tuple of method names in that order.
27
+ """
28
+
29
+ def _is_valid_func(name: str):
30
+ return not (name.startswith("_") or name in _UTILITY_METHODS) and callable(getattr(self, name))
31
+
32
+ def _sort_helper(method_name: str):
33
+ name_parts = method_name.split("_")
34
+ num_part = len(name_parts)
35
+ if num_part == 2:
36
+ return f"{name_parts[1]}_{name_parts[0]}"
37
+ if num_part == 3:
38
+ return f"{{{name_parts[0]}_"
39
+ return "_" + method_name
40
+
41
+ return tuple(
42
+ sorted(
43
+ (func for func in dir(self) if _is_valid_func(func)),
44
+ key=_sort_helper,
45
+ )
46
+ )
47
+
48
+ @lru_cache(maxsize=50)
49
+ def method_signature(self, method_name: str) -> str:
50
+ """Return signature for a method in this resource group, result is cached.
51
+
52
+ For example ``client.pulls.method_signature("list_issues")`` would return:
53
+ list_issues(*, number: Union[int, str], owner: Optional[str] = None, repo: Optional[str] = None) -> List[Issue]
54
+
55
+ :param method_name: Attribute name of a callable on this resource.
56
+ :returns: Formatted signature.
57
+ """
58
+ import inspect
59
+
60
+ return method_name + str(inspect.signature(getattr(self, method_name))).replace(_DATA_MODEL_PATH, "")
@@ -0,0 +1,31 @@
1
+ """ASCII art banner for command-line interface."""
2
+
3
+ from functools import lru_cache
4
+ import sys
5
+
6
+ # ANSI Colour Codes
7
+ CREDBG = "\033[41m"
8
+ CRED = "\033[91m"
9
+ CBLU = "\033[94m"
10
+ CCYN = "\033[96m"
11
+ CGRN = "\033[92m"
12
+ CYEL = "\033[41m"
13
+ CEND = "\033[0m"
14
+
15
+ # ASCII Art Banner
16
+ BANNER = r"""
17
+ _____ _ _ _____ _ _____ _____
18
+ / ____(_) | / ____| | | /\ | __ \_ _|
19
+ | | __ _| |_| | ___ __| | ___ / \ | |__) || |
20
+ | | |_ | | __| | / _ \ / _` |/ _ \ / /\ \ | ___/ | |
21
+ | |__| | | |_| |___| (_) | (_| | __/ / ____ \| | _| |_
22
+ \_____|_|\__|\_____\___/ \__,_|\___| /_/ \_\_| |_____|
23
+ """.strip("\n")
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def format_default_welcome(version: str) -> str:
28
+ """Colored banner and version line for the no-arguments launcher (plain if not a TTY)."""
29
+ if sys.stdout.isatty():
30
+ return f"{CRED}{BANNER}{CEND}\n{CBLU} Ver. {version}{CEND}\n\n"
31
+ return f"{BANNER}\n Ver. {version}\n\n"
@@ -0,0 +1,366 @@
1
+ """Command-line interface for the GitCode SDK."""
2
+
3
+ import argparse
4
+ import inspect
5
+ import json
6
+ import re
7
+ import sys
8
+ import textwrap
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+ from typing import Any, List, Optional, Union, get_args, get_origin
12
+
13
+ import httpx
14
+
15
+ from . import GitCode, __version__
16
+ from ._base_client import DEFAULT_BASE_URL, DEFAULT_TOKEN_ENV
17
+ from ._cli_banner import format_default_welcome
18
+ from ._exceptions import GitCodeError
19
+ from .resources._shared import SyncResource
20
+
21
+
22
+ class _CLIHelpFormatter(argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
23
+ """Preserve paragraph breaks in descriptions; wider columns for signatures."""
24
+
25
+ def __init__(self, prog: str) -> None:
26
+ super().__init__(prog, width=108, max_help_position=32)
27
+
28
+
29
+ def _plain_cli_inline(text: str) -> str:
30
+ """Strip Sphinx-style inline markup (roles, doubled literals) for terminal text."""
31
+ if not text:
32
+ return ""
33
+ t = re.sub(r"``\s*([^`]+?)\s*``", r"\1", text)
34
+ t = re.sub(r":\w+:`([^`]*)`", r"\1", t)
35
+ t = re.sub(r"`([^`]+)`", r"\1", t)
36
+ return t
37
+
38
+
39
+ def _unwrap_optional(annotation: Any) -> Any:
40
+ origin = get_origin(annotation)
41
+ if origin is Union:
42
+ args = [arg for arg in get_args(annotation) if arg is not type(None)]
43
+ if len(args) == 1:
44
+ return args[0]
45
+ return annotation
46
+
47
+
48
+ def _is_list_annotation(annotation: Any) -> bool:
49
+ annotation = _unwrap_optional(annotation)
50
+ return get_origin(annotation) in (list, List)
51
+
52
+
53
+ def _list_item_type(annotation: Any) -> Any:
54
+ annotation = _unwrap_optional(annotation)
55
+ args = get_args(annotation)
56
+ if not args:
57
+ return str
58
+ item_type = _unwrap_optional(args[0])
59
+ if item_type in (int, float):
60
+ return item_type
61
+ return str
62
+
63
+
64
+ def _argument_kwargs(parameter: inspect.Parameter) -> dict[str, Any]:
65
+ annotation = _unwrap_optional(parameter.annotation)
66
+ if annotation is bool:
67
+ return {"action": argparse.BooleanOptionalAction, "default": parameter.default}
68
+ if _is_list_annotation(parameter.annotation):
69
+ return {"nargs": "+", "type": _list_item_type(parameter.annotation), "default": None}
70
+ if annotation in (int, float):
71
+ return {"type": annotation}
72
+ return {"type": str}
73
+
74
+
75
+ def _first_doc_line(obj: Any) -> str:
76
+ doc = inspect.getdoc(obj).removeprefix("Synchronous ").capitalize() or ""
77
+ for line in doc.splitlines():
78
+ stripped = line.strip()
79
+ if stripped:
80
+ return _plain_cli_inline(stripped)
81
+ return ""
82
+
83
+
84
+ def _method_cli_summary(doc: str) -> str:
85
+ """Text before the first ':param' in the docstring, collapsed to one line for CLI help."""
86
+ return doc.split(":param", 1)[0].strip().replace("\n", " ")
87
+
88
+
89
+ def _resource_types() -> dict[str, type[SyncResource]]:
90
+ resources: dict[str, type[SyncResource]] = {}
91
+ for name, annotation in GitCode.__annotations__.items():
92
+ if inspect.isclass(annotation) and issubclass(annotation, SyncResource):
93
+ resources[name] = annotation
94
+ return resources
95
+
96
+
97
+ def _probe_gitcode() -> GitCode:
98
+ """Lightweight client used only while building -h/--help metadata (no network)."""
99
+ transport = httpx.MockTransport(lambda _request: httpx.Response(200, json={}))
100
+ return GitCode(api_key="__cli_help__", http_client=httpx.Client(transport=transport))
101
+
102
+
103
+ def _resource_commands_epilog(resource: SyncResource) -> str:
104
+ """List CLI subcommand names in the same order as resource.methods on the client."""
105
+ kebab_names = [_kebab_case(name) for name in resource.methods]
106
+ joined = ", ".join(kebab_names)
107
+ wrapped = textwrap.fill(
108
+ joined,
109
+ width=120,
110
+ initial_indent=" ",
111
+ subsequent_indent=" ",
112
+ break_long_words=False,
113
+ break_on_hyphens=False,
114
+ )
115
+ return "Subcommands (stable SDK order, same as resource.methods):\n" + wrapped
116
+
117
+
118
+ def _kebab_case(value: str) -> str:
119
+ return value.replace("_", "-")
120
+
121
+
122
+ def _load_json_value(raw: str) -> Any:
123
+ if raw.startswith("@"):
124
+ return json.loads(Path(raw[1:]).read_text(encoding="utf-8"))
125
+ return json.loads(raw)
126
+
127
+
128
+ def _parse_scalar(raw: str) -> Any:
129
+ try:
130
+ return _load_json_value(raw)
131
+ except (OSError, ValueError, json.JSONDecodeError):
132
+ return raw
133
+
134
+
135
+ def _parse_key_value(raw: str) -> tuple[str, Any]:
136
+ if "=" not in raw:
137
+ raise ValueError(f"Expected KEY=VALUE, got: {raw}")
138
+ key, value = raw.split("=", maxsplit=1)
139
+ if not key:
140
+ raise ValueError(f"Expected KEY=VALUE, got: {raw}")
141
+ return key, _parse_scalar(value)
142
+
143
+
144
+ def _to_data(value: Any) -> Any:
145
+ if hasattr(value, "to_dict") and callable(value.to_dict):
146
+ return _to_data(value.to_dict())
147
+ if isinstance(value, Mapping):
148
+ return {key: _to_data(item) for key, item in value.items()}
149
+ if isinstance(value, list):
150
+ return [_to_data(item) for item in value]
151
+ return value
152
+
153
+
154
+ def _write_output(value: Any, *, output_file: Optional[str], compact: bool) -> None:
155
+ if value is None:
156
+ return
157
+ if isinstance(value, bytes):
158
+ if output_file:
159
+ Path(output_file).write_bytes(value)
160
+ else:
161
+ sys.stdout.buffer.write(value)
162
+ return
163
+
164
+ payload = _to_data(value)
165
+ if isinstance(payload, str):
166
+ text = payload
167
+ else:
168
+ text = json.dumps(payload, indent=None if compact else 2, ensure_ascii=True, sort_keys=True)
169
+
170
+ if output_file:
171
+ Path(output_file).write_text(text + ("\n" if not text.endswith("\n") else ""), encoding="utf-8")
172
+ else:
173
+ print(text)
174
+
175
+
176
+ def _invocation_parent_parser() -> argparse.ArgumentParser:
177
+ """Flags for real API calls (attached only to leaf METHOD parsers, not top-level usage)."""
178
+ parser = argparse.ArgumentParser(add_help=False)
179
+ parser.add_argument("--api-key", help=f"GitCode access token. Defaults to {DEFAULT_TOKEN_ENV}.")
180
+ parser.add_argument("--owner", help="Default repository owner.")
181
+ parser.add_argument("--repo", help="Default repository name.")
182
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Base URL for the REST API.")
183
+ parser.add_argument("--timeout", type=float, default=None, help="Request timeout in seconds.")
184
+ parser.add_argument("--output-file", help="Write the response to a file instead of stdout.")
185
+ parser.add_argument("--compact", action="store_true", help="Print JSON without indentation.")
186
+ return parser
187
+
188
+
189
+ def _root_banner() -> str:
190
+ return "Connection and defaults are documented on each method's help: %(prog)s RESOURCE METHOD -h."
191
+
192
+
193
+ def build_parser() -> argparse.ArgumentParser:
194
+ common = _invocation_parent_parser()
195
+ epilog = """\
196
+ Examples:
197
+ %(prog)s pulls list --api-key "$GITCODE_ACCESS_TOKEN" --owner my-org --repo my-repo
198
+ %(prog)s users me --api-key "$GITCODE_ACCESS_TOKEN"
199
+ %(prog)s pulls list --set only_count=true --set reviewer=demo
200
+
201
+ Extra keyword arguments (**params / **payload on some methods):
202
+ Repeat --set key=value (value is JSON if it parses as JSON; otherwise a string).
203
+ --set-json merges one JSON object (or @path/to/file.json) into those kwargs.
204
+
205
+ Each resource -h lists subcommands in SDK order (resource.methods).
206
+ Each method -h opens with resource.method_signature("<name>") from the Python SDK.
207
+ """
208
+ parser = argparse.ArgumentParser(
209
+ prog="gitcode-api",
210
+ usage="%(prog)s [-h] [--version] RESOURCE ...",
211
+ description=_root_banner(),
212
+ epilog=epilog,
213
+ formatter_class=_CLIHelpFormatter,
214
+ )
215
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
216
+
217
+ client = _probe_gitcode()
218
+ try:
219
+ resource_parsers = parser.add_subparsers(
220
+ dest="resource",
221
+ required=True,
222
+ metavar="RESOURCE",
223
+ title="resources",
224
+ description="Pick a resource group (same attribute names as on GitCode, e.g. pulls, repos).",
225
+ )
226
+ for resource_name, resource_type in _resource_types().items():
227
+ resource = getattr(client, resource_name)
228
+ class_doc = inspect.getdoc(resource_type).removeprefix("Synchronous ").capitalize() or ""
229
+ class_lead = class_doc.split("\n\n", maxsplit=1)[0].strip() if class_doc else ""
230
+ class_lead = _plain_cli_inline(class_lead) if class_lead else ""
231
+ resource_desc_parts = [
232
+ class_lead or _first_doc_line(resource_type),
233
+ "",
234
+ _resource_commands_epilog(resource),
235
+ ]
236
+ resource_parser = resource_parsers.add_parser(
237
+ _kebab_case(resource_name),
238
+ help=_first_doc_line(resource_type) or f"GitCode {resource_name} endpoints.",
239
+ description="\n".join(resource_desc_parts),
240
+ formatter_class=_CLIHelpFormatter,
241
+ )
242
+ method_parsers = resource_parser.add_subparsers(
243
+ dest="method",
244
+ required=True,
245
+ title="methods",
246
+ metavar="METHOD",
247
+ description=(
248
+ "Method names use kebab-case. The opening line of each command's help matches "
249
+ "resource.method_signature('<name>') on the Python client."
250
+ ),
251
+ )
252
+
253
+ for method_name in resource.methods:
254
+ method = getattr(resource, method_name)
255
+ sig_line = resource.method_signature(method_name)
256
+ doc = inspect.getdoc(method).removeprefix("Synchronous ").capitalize() or ""
257
+ summary = _method_cli_summary(doc)
258
+ if summary and len(summary) > 90:
259
+ method_help = summary[:87] + "..."
260
+ else:
261
+ method_help = summary or (sig_line if len(sig_line) <= 90 else sig_line[:87] + "...")
262
+ method_description = f"{summary}\n\n{sig_line}" if summary else sig_line
263
+
264
+ method_parser = method_parsers.add_parser(
265
+ _kebab_case(method_name),
266
+ help=method_help,
267
+ description=method_description,
268
+ formatter_class=_CLIHelpFormatter,
269
+ parents=[common],
270
+ )
271
+ signature = inspect.signature(method)
272
+ for parameter in signature.parameters.values():
273
+ if parameter.name == "self":
274
+ continue
275
+ if parameter.kind == inspect.Parameter.VAR_KEYWORD:
276
+ method_parser.add_argument(
277
+ "--set",
278
+ dest="extra_items",
279
+ action="append",
280
+ default=None,
281
+ metavar="KEY=VALUE",
282
+ help="Extra keyword argument (merged into **params / **payload).",
283
+ )
284
+ method_parser.add_argument(
285
+ "--set-json",
286
+ dest="extra_json",
287
+ default=None,
288
+ metavar="JSON_OR_@FILE",
289
+ help="JSON object merged into extra keyword arguments.",
290
+ )
291
+ continue
292
+
293
+ flag = f"--{parameter.name.replace('_', '-')}"
294
+ if flag in method_parser._option_string_actions:
295
+ continue
296
+ kwargs = _argument_kwargs(parameter)
297
+ kwargs["dest"] = parameter.name
298
+ kwargs["required"] = parameter.default is inspect.Signature.empty
299
+ method_parser.add_argument(flag, **kwargs)
300
+
301
+ method_parser.set_defaults(resource_name=resource_name, method_name=method_name)
302
+ finally:
303
+ client.close()
304
+
305
+ return parser
306
+
307
+
308
+ def _collect_kwargs(args: argparse.Namespace, method: Any) -> dict[str, Any]:
309
+ signature = inspect.signature(method)
310
+ kwargs: dict[str, Any] = {}
311
+ for parameter in signature.parameters.values():
312
+ if parameter.name == "self":
313
+ continue
314
+ if parameter.kind == inspect.Parameter.VAR_KEYWORD:
315
+ extra_kwargs: dict[str, Any] = {}
316
+ if getattr(args, "extra_json", None):
317
+ raw_extra = _load_json_value(args.extra_json)
318
+ if not isinstance(raw_extra, dict):
319
+ raise ValueError("--set-json must decode to a JSON object.")
320
+ extra_kwargs.update(raw_extra)
321
+ for item in getattr(args, "extra_items", []) or []:
322
+ key, value = _parse_key_value(item)
323
+ extra_kwargs[key] = value
324
+ kwargs.update(extra_kwargs)
325
+ continue
326
+
327
+ value = getattr(args, parameter.name)
328
+ if value is None:
329
+ if parameter.default is inspect.Signature.empty:
330
+ raise ValueError(f"--{parameter.name.replace('_', '-')} is required.")
331
+ continue
332
+ kwargs[parameter.name] = value
333
+ return kwargs
334
+
335
+
336
+ def main(argv: Optional[Sequence[str]] = None) -> int:
337
+ parser = build_parser()
338
+ effective = list(sys.argv[1:] if argv is None else argv)
339
+ if not effective:
340
+ print(format_default_welcome(__version__), end="")
341
+ saved_epilog = parser.epilog
342
+ parser.epilog = None
343
+ try:
344
+ parser.print_help()
345
+ finally:
346
+ parser.epilog = saved_epilog
347
+ return 0
348
+ args = parser.parse_args(effective)
349
+
350
+ try:
351
+ with GitCode(
352
+ api_key=args.api_key,
353
+ owner=args.owner,
354
+ repo=args.repo,
355
+ base_url=args.base_url,
356
+ timeout=args.timeout,
357
+ ) as client:
358
+ resource = getattr(client, args.resource_name)
359
+ method = getattr(resource, args.method_name)
360
+ result = method(**_collect_kwargs(args, method))
361
+ except (GitCodeError, OSError, TypeError, ValueError) as exc: # pragma: no cover - integration style
362
+ print(f"error: {exc}", file=sys.stderr)
363
+ return 1
364
+
365
+ _write_output(result, output_file=args.output_file, compact=args.compact)
366
+ return 0
@@ -3,10 +3,11 @@
3
3
  from typing import Any, Dict, List, Optional, Union
4
4
 
5
5
  from .._base_client import AsyncAPIClient, SyncAPIClient
6
+ from .._base_resource import BaseResource
6
7
  from .._models import APIObject, ModelT, as_model, as_model_list
7
8
 
8
9
 
9
- class SyncResource:
10
+ class SyncResource(BaseResource):
10
11
  """Base class for synchronous resource groups."""
11
12
 
12
13
  def __init__(self, client: SyncAPIClient) -> None:
@@ -74,7 +75,7 @@ class SyncResource:
74
75
  return APIObject({"value": data})
75
76
 
76
77
 
77
- class AsyncResource:
78
+ class AsyncResource(BaseResource):
78
79
  """Base class for asynchronous resource groups."""
79
80
 
80
81
  def __init__(self, client: AsyncAPIClient) -> None:
@@ -476,7 +476,7 @@ class PullsResource(SyncResource):
476
476
  """Create a pull request.
477
477
 
478
478
  :param title: Pull request title.
479
- :param head: Source branch ref (head branch).
479
+ :param head: Source branch ref (head branch, in forks use ``owner:branch`` like "SushiNinja:develop").
480
480
  :param base: Target branch ref (base branch).
481
481
  :param owner: Repository owner path. Uses the client default when omitted.
482
482
  :param repo: Repository path. Uses the client default when omitted.
@@ -490,7 +490,7 @@ class PullsResource(SyncResource):
490
490
  :param prune_source_branch: Whether to delete the source branch after merge when applicable.
491
491
  :param squash: Squash-merge preference where supported.
492
492
  :param squash_commit_message: Custom squash commit message.
493
- :param fork_path: Fork namespace/path when opening a PR from a fork.
493
+ :param fork_path: Fork ``owner/repo`` when opening a PR from a fork, like "SushiNinja/agent-core-contrib".
494
494
  :returns: Created pull request.
495
495
  """
496
496
  return self._model(
@@ -1662,7 +1662,7 @@ class AsyncPullsResource(AsyncResource):
1662
1662
  """Create a pull request.
1663
1663
 
1664
1664
  :param title: Pull request title.
1665
- :param head: Source branch ref (head branch).
1665
+ :param head: Source branch ref (head branch, in forks use ``owner:branch`` like "SushiNinja:develop").
1666
1666
  :param base: Target branch ref (base branch).
1667
1667
  :param owner: Repository owner path. Uses the client default when omitted.
1668
1668
  :param repo: Repository path. Uses the client default when omitted.
@@ -1676,7 +1676,7 @@ class AsyncPullsResource(AsyncResource):
1676
1676
  :param prune_source_branch: Whether to delete the source branch after merge when applicable.
1677
1677
  :param squash: Squash-merge preference where supported.
1678
1678
  :param squash_commit_message: Custom squash commit message.
1679
- :param fork_path: Fork namespace/path when opening a PR from a fork.
1679
+ :param fork_path: Fork ``owner/repo`` when opening a PR from a fork, like "SushiNinja/agent-core-contrib".
1680
1680
  :returns: Created pull request.
1681
1681
  """
1682
1682
  return await self._model(
@@ -0,0 +1 @@
1
+ 1.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcode-api
3
- Version: 1.1.3
3
+ Version: 1.2.0
4
4
  Summary: Easy to use Python SDK for the GitCode REST API, community-maintained.
5
5
  Author-email: Hugo Huang <hugo@hugohuang.com>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Dynamic: license-file
29
29
 
30
30
  [![PyPI - Version](https://img.shields.io/pypi/v/gitcode-api?link=https%3A%2F%2Fpypi.org%2Fproject%2Fgitcode-api%2F)](https://pypi.org/project/gitcode-api) [![GitHub Badge](https://img.shields.io/badge/github-repo-blue?logo=github&link=https%3A%2F%2Fgithub.com%2FTrenza1ore%2FGitCode-API)](https://github.com/Trenza1ore/GitCode-API) [![GitCode Badge](https://img.shields.io/badge/gitcode-repo-brown?logo=gitcode&link=https%3A%2F%2Fgitcode.com%2FSushiNinja%2FGitCode-API)](https://gitcode.com/SushiNinja/GitCode-API) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/gitcode-api?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=RED&left_text=downloads)](https://pepy.tech/projects/gitcode-api)
31
31
 
32
- [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md) [![English README](https://img.shields.io/badge/English-README-blue?style=for-the-badge&logo=googledocs&link=README.md)](README.md)
32
+ [![Docs](https://img.shields.io/badge/%E6%96%87%E6%A1%A3-Docs-cyan?style=for-the-badge&logo=readthedocs&link=https%3A%2F%2Fgitcode-api.readthedocs.io%2Fen%2Flatest%2Findex.html)](https://gitcode-api.readthedocs.io) [![中文README](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README-brown?style=for-the-badge&logo=googledocs&link=README.zh.md)](README.zh.md)
33
33
 
34
34
  `gitcode-api` is a community-maintained Python SDK for the GitCode REST API. It provides easy-to-use synchronous and asynchronous clients, repository-scoped helpers, and lightweight response models so you can work with GitCode from Python without hand-writing raw HTTP requests.
35
35
 
@@ -37,7 +37,7 @@ Dynamic: license-file
37
37
 
38
38
  - Community project for developers who want a practical GitCode Python library.
39
39
  - Sync and async clients with a consistent API surface.
40
- - Resource namespaces such as `client.repos`, `client.pulls`, and `client.users`.
40
+ - Resource groups such as `client.repos`, `client.pulls`, and `client.users`.
41
41
  - Repository defaults via `owner=` and `repo=` on the client.
42
42
  - Sphinx docs plus a mirrored GitCode REST API reference in `docs/`.
43
43
 
@@ -203,6 +203,8 @@ Both `GitCode` and `AsyncGitCode` expose:
203
203
  - `releases`, `tags`, and `webhooks`
204
204
  - `users`, `orgs`, `search`, and `oauth`
205
205
 
206
+ Every resource group inherits a cached `methods` property from the shared resource base: a `tuple` of public callable names in stable SDK order (underscore-segment sort key, not plain A–Z on the full identifier). Private names and the introspection helpers `methods` and `method_signature` are omitted. For example, `client.pulls.methods` helps with discovery or tooling without reading the full manual list. For one method’s parameters and return type, call `client.pulls.method_signature("list_issues")` (a cached string from `inspect.signature`, with `gitcode_api._models.` stripped from annotations).
207
+
206
208
  ## Examples
207
209
 
208
210
  Runnable examples live in `examples/`:
@@ -4,6 +4,8 @@ pyproject.toml
4
4
  gitcode_api/__init__.py
5
5
  gitcode_api/__main__.py
6
6
  gitcode_api/_base_client.py
7
+ gitcode_api/_base_resource.py
8
+ gitcode_api/_cli_banner.py
7
9
  gitcode_api/_client.py
8
10
  gitcode_api/_exceptions.py
9
11
  gitcode_api/_models.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gitcode-api"
3
- version = "1.1.3"
3
+ version = "1.2.0"
4
4
  description = "Easy to use Python SDK for the GitCode REST API, community-maintained."
5
5
  keywords = ["gitcode", "git", "devops", "api", "sdk", "python", "httpx", "client"]
6
6
  readme = "README.md"
@@ -72,3 +72,10 @@ addopts = ["-v"]
72
72
  [tool.ruff]
73
73
  line-length = 120
74
74
  target-version = "py39"
75
+
76
+ [tool.pylint.MAIN]
77
+ ignore-paths = ["^(examples|tests|docs|scripts)/.*"]
78
+ py-version = "3.9"
79
+
80
+ [tool.pylint.FORMAT]
81
+ max-line-length = 120
@@ -1,9 +1,13 @@
1
+ import sys
2
+ from contextlib import redirect_stdout
3
+ from io import StringIO
1
4
  from pathlib import Path
2
- from typing import Any
5
+ from typing import Any, Dict
3
6
 
4
7
  import httpx
8
+ import pytest
5
9
 
6
- from gitcode_api.cli import build_parser, main
10
+ from gitcode_api.cli import _kebab_case, _probe_gitcode, build_parser, main
7
11
 
8
12
 
9
13
  def _mock_sync_client(monkeypatch: Any, handler: Any) -> None:
@@ -18,6 +22,70 @@ def _mock_sync_client(monkeypatch: Any, handler: Any) -> None:
18
22
  monkeypatch.setattr(httpx, "Client", client_with_mock_transport)
19
23
 
20
24
 
25
+ def test_cli_no_args_prints_help_and_exits_zero(capsys: Any, monkeypatch: Any) -> None:
26
+ monkeypatch.setattr(sys, "argv", ["gitcode-api"])
27
+ assert main() == 0
28
+ out = capsys.readouterr().out
29
+ assert "usage:" in out.lower()
30
+ assert "gitcode-api" in out
31
+ assert "_____" in out
32
+ assert "Examples:" not in out
33
+
34
+
35
+ def test_cli_empty_argv_prints_help(capsys: Any) -> None:
36
+ assert main([]) == 0
37
+ out = capsys.readouterr().out.lower()
38
+ assert "usage:" in out
39
+ assert "examples:" not in out
40
+
41
+
42
+ def test_cli_root_explicit_help_includes_epilog() -> None:
43
+ buf = StringIO()
44
+ with redirect_stdout(buf):
45
+ with pytest.raises(SystemExit) as exc_info:
46
+ build_parser().parse_args(["--help"])
47
+ assert exc_info.value.code == 0
48
+ assert "Examples:" in buf.getvalue()
49
+
50
+
51
+ def test_cli_method_subcommands_follow_resource_methods_order() -> None:
52
+ """CLI lists methods in the same order as client.<resource>.methods."""
53
+ parser = build_parser()
54
+ client = _probe_gitcode()
55
+ try:
56
+ resources_action = next(
57
+ action for action in parser._actions if getattr(action, "choices", None) and "pulls" in action.choices
58
+ )
59
+ pulls_parser = resources_action.choices["pulls"]
60
+ methods_action = next(
61
+ action for action in pulls_parser._actions if getattr(action, "choices", None)
62
+ )
63
+ cli_kebab_order = list(methods_action.choices.keys())
64
+ assert cli_kebab_order == [_kebab_case(name) for name in client.pulls.methods]
65
+ finally:
66
+ client.close()
67
+
68
+
69
+ def test_cli_method_help_description_has_summary_then_signature() -> None:
70
+ client = _probe_gitcode()
71
+ try:
72
+ parser = build_parser()
73
+ resources_action = next(
74
+ action for action in parser._actions if getattr(action, "choices", None) and "pulls" in action.choices
75
+ )
76
+ pulls_parser = resources_action.choices["pulls"]
77
+ methods_action = next(
78
+ action for action in pulls_parser._actions if getattr(action, "choices", None)
79
+ )
80
+ list_parser = methods_action.choices["list"]
81
+ sig = client.pulls.method_signature("list")
82
+ assert list_parser.description is not None
83
+ assert list_parser.description.startswith("List pull requests for a repository.")
84
+ assert list_parser.description.endswith(sig)
85
+ finally:
86
+ client.close()
87
+
88
+
21
89
  def test_cli_build_parser_exposes_generated_commands() -> None:
22
90
  parser = build_parser()
23
91
  args = parser.parse_args(
@@ -38,7 +106,7 @@ def test_cli_build_parser_exposes_generated_commands() -> None:
38
106
 
39
107
 
40
108
  def test_cli_invokes_resource_methods_and_prints_json(capsys: Any, monkeypatch: Any) -> None:
41
- captured: dict[str, Any] = {}
109
+ captured: Dict[str, Any] = {}
42
110
 
43
111
  def handler(request: httpx.Request) -> httpx.Response:
44
112
  captured["url"] = str(request.url)
@@ -66,7 +134,7 @@ def test_cli_invokes_resource_methods_and_prints_json(capsys: Any, monkeypatch:
66
134
 
67
135
 
68
136
  def test_cli_supports_extra_kwargs_via_set_flags(capsys: Any, monkeypatch: Any) -> None:
69
- captured: dict[str, Any] = {}
137
+ captured: Dict[str, Any] = {}
70
138
 
71
139
  def handler(request: httpx.Request) -> httpx.Response:
72
140
  captured["params"] = dict(request.url.params)
@@ -13,7 +13,7 @@ def test_client_reads_api_key_from_environment(monkeypatch: pytest.MonkeyPatch)
13
13
  assert client.api_key == "env-token"
14
14
 
15
15
 
16
- def test_sync_client_exposes_resource_namespaces() -> None:
16
+ def test_sync_client_exposes_resource_groups() -> None:
17
17
  with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))) as http_client:
18
18
  client = GitCode(api_key="test-token", http_client=http_client)
19
19
  assert client.repos is not None
@@ -23,7 +23,7 @@ def test_sync_client_exposes_resource_namespaces() -> None:
23
23
 
24
24
 
25
25
  @pytest.mark.asyncio
26
- async def test_async_client_exposes_resource_namespaces() -> None:
26
+ async def test_async_client_exposes_resource_groups() -> None:
27
27
  async_client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})))
28
28
  client = AsyncGitCode(api_key="test-token", http_client=async_client)
29
29
  try:
@@ -50,15 +50,12 @@ def test_sync_client_context_manager_closes_owned_http_client(monkeypatch: pytes
50
50
  assert client._client.is_closed is True
51
51
 
52
52
 
53
- def test_sync_client_context_manager_does_not_close_injected_http_client() -> None:
53
+ def test_sync_client_context_manager_closes_injected_http_client() -> None:
54
54
  transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
55
55
  http_client = httpx.Client(transport=transport)
56
- try:
57
- with GitCode(api_key="test-token", http_client=http_client, owner="o", repo="r"):
58
- pass
56
+ with GitCode(api_key="test-token", http_client=http_client, owner="o", repo="r"):
59
57
  assert http_client.is_closed is False
60
- finally:
61
- http_client.close()
58
+ assert http_client.is_closed is True
62
59
 
63
60
 
64
61
  @pytest.mark.asyncio
@@ -78,12 +75,9 @@ async def test_async_client_context_manager_closes_owned_http_client(monkeypatch
78
75
 
79
76
 
80
77
  @pytest.mark.asyncio
81
- async def test_async_client_context_manager_does_not_close_injected_http_client() -> None:
78
+ async def test_async_client_context_manager_closes_injected_http_client() -> None:
82
79
  transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
83
80
  http_client = httpx.AsyncClient(transport=transport)
84
- try:
85
- async with AsyncGitCode(api_key="test-token", http_client=http_client, owner="o", repo="r"):
86
- pass
81
+ async with AsyncGitCode(api_key="test-token", http_client=http_client, owner="o", repo="r"):
87
82
  assert http_client.is_closed is False
88
- finally:
89
- await http_client.aclose()
83
+ assert http_client.is_closed is True
@@ -1,255 +0,0 @@
1
- """Command-line interface for the GitCode SDK."""
2
-
3
- import argparse
4
- import inspect
5
- import json
6
- import sys
7
- from collections.abc import Mapping, Sequence
8
- from pathlib import Path
9
- from typing import Any, List, Optional, Union, get_args, get_origin
10
-
11
- from . import GitCode, __version__
12
- from ._base_client import DEFAULT_BASE_URL, DEFAULT_TOKEN_ENV
13
- from ._exceptions import GitCodeError
14
- from .resources._shared import SyncResource
15
-
16
-
17
- def _unwrap_optional(annotation: Any) -> Any:
18
- origin = get_origin(annotation)
19
- if origin is Union:
20
- args = [arg for arg in get_args(annotation) if arg is not type(None)]
21
- if len(args) == 1:
22
- return args[0]
23
- return annotation
24
-
25
-
26
- def _is_list_annotation(annotation: Any) -> bool:
27
- annotation = _unwrap_optional(annotation)
28
- return get_origin(annotation) in (list, List)
29
-
30
-
31
- def _list_item_type(annotation: Any) -> Any:
32
- annotation = _unwrap_optional(annotation)
33
- args = get_args(annotation)
34
- if not args:
35
- return str
36
- item_type = _unwrap_optional(args[0])
37
- if item_type in (int, float):
38
- return item_type
39
- return str
40
-
41
-
42
- def _argument_kwargs(parameter: inspect.Parameter) -> dict[str, Any]:
43
- annotation = _unwrap_optional(parameter.annotation)
44
- if annotation is bool:
45
- return {"action": argparse.BooleanOptionalAction, "default": parameter.default}
46
- if _is_list_annotation(parameter.annotation):
47
- return {"nargs": "+", "type": _list_item_type(parameter.annotation), "default": None}
48
- if annotation in (int, float):
49
- return {"type": annotation}
50
- return {"type": str}
51
-
52
-
53
- def _first_doc_line(obj: Any) -> str:
54
- doc = inspect.getdoc(obj) or ""
55
- return doc.splitlines()[0] if doc else ""
56
-
57
-
58
- def _resource_types() -> dict[str, type[SyncResource]]:
59
- resources: dict[str, type[SyncResource]] = {}
60
- for name, annotation in GitCode.__annotations__.items():
61
- if inspect.isclass(annotation) and issubclass(annotation, SyncResource):
62
- resources[name] = annotation
63
- return resources
64
-
65
-
66
- def _iter_resource_methods(resource_type: type[SyncResource]) -> list[tuple[str, Any]]:
67
- methods: list[tuple[str, Any]] = []
68
- for name, value in resource_type.__dict__.items():
69
- if name.startswith("_") or not inspect.isfunction(value):
70
- continue
71
- methods.append((name, value))
72
- return methods
73
-
74
-
75
- def _kebab_case(value: str) -> str:
76
- return value.replace("_", "-")
77
-
78
-
79
- def _load_json_value(raw: str) -> Any:
80
- if raw.startswith("@"):
81
- return json.loads(Path(raw[1:]).read_text(encoding="utf-8"))
82
- return json.loads(raw)
83
-
84
-
85
- def _parse_scalar(raw: str) -> Any:
86
- try:
87
- return _load_json_value(raw)
88
- except (OSError, ValueError, json.JSONDecodeError):
89
- return raw
90
-
91
-
92
- def _parse_key_value(raw: str) -> tuple[str, Any]:
93
- if "=" not in raw:
94
- raise ValueError(f"Expected KEY=VALUE, got: {raw}")
95
- key, value = raw.split("=", maxsplit=1)
96
- if not key:
97
- raise ValueError(f"Expected KEY=VALUE, got: {raw}")
98
- return key, _parse_scalar(value)
99
-
100
-
101
- def _to_data(value: Any) -> Any:
102
- if hasattr(value, "to_dict") and callable(value.to_dict):
103
- return _to_data(value.to_dict())
104
- if isinstance(value, Mapping):
105
- return {key: _to_data(item) for key, item in value.items()}
106
- if isinstance(value, list):
107
- return [_to_data(item) for item in value]
108
- return value
109
-
110
-
111
- def _write_output(value: Any, *, output_file: Optional[str], compact: bool) -> None:
112
- if value is None:
113
- return
114
- if isinstance(value, bytes):
115
- if output_file:
116
- Path(output_file).write_bytes(value)
117
- else:
118
- sys.stdout.buffer.write(value)
119
- return
120
-
121
- payload = _to_data(value)
122
- if isinstance(payload, str):
123
- text = payload
124
- else:
125
- text = json.dumps(payload, indent=None if compact else 2, ensure_ascii=True, sort_keys=True)
126
-
127
- if output_file:
128
- Path(output_file).write_text(text + ("\n" if not text.endswith("\n") else ""), encoding="utf-8")
129
- else:
130
- print(text)
131
-
132
-
133
- def _global_parent_parser() -> argparse.ArgumentParser:
134
- parser = argparse.ArgumentParser(add_help=False)
135
- parser.add_argument("--api-key", help=f"GitCode access token. Defaults to {DEFAULT_TOKEN_ENV}.")
136
- parser.add_argument("--owner", help="Default repository owner.")
137
- parser.add_argument("--repo", help="Default repository name.")
138
- parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Base URL for the GitCode REST API.")
139
- parser.add_argument("--timeout", type=float, default=None, help="Request timeout in seconds.")
140
- parser.add_argument("--output-file", help="Write the response to a file instead of stdout.")
141
- parser.add_argument("--compact", action="store_true", help="Print JSON without indentation.")
142
- return parser
143
-
144
-
145
- def build_parser() -> argparse.ArgumentParser:
146
- common = _global_parent_parser()
147
- parser = argparse.ArgumentParser(
148
- prog="gitcode-api",
149
- description="Invoke any synchronous gitcode-api resource method from the command line.",
150
- epilog='Use `--set key=value` and `--set-json \'{"key": "value"}\'` for methods with `**params` or `**payload`.',
151
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
152
- parents=[common],
153
- )
154
- parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
155
-
156
- resource_parsers = parser.add_subparsers(dest="resource", required=True)
157
- for resource_name, resource_type in _resource_types().items():
158
- resource_parser = resource_parsers.add_parser(
159
- _kebab_case(resource_name),
160
- help=_first_doc_line(resource_type),
161
- )
162
- method_parsers = resource_parser.add_subparsers(dest="method", required=True)
163
-
164
- for method_name, method in _iter_resource_methods(resource_type):
165
- method_parser = method_parsers.add_parser(
166
- _kebab_case(method_name),
167
- help=_first_doc_line(method),
168
- description=inspect.getdoc(method),
169
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
170
- parents=[common],
171
- )
172
- signature = inspect.signature(method)
173
- for parameter in signature.parameters.values():
174
- if parameter.name == "self":
175
- continue
176
- if parameter.kind == inspect.Parameter.VAR_KEYWORD:
177
- method_parser.add_argument(
178
- "--set",
179
- dest="extra_items",
180
- action="append",
181
- default=None,
182
- metavar="KEY=VALUE",
183
- help="Additional keyword arguments for `**params` or `**payload`.",
184
- )
185
- method_parser.add_argument(
186
- "--set-json",
187
- dest="extra_json",
188
- default=None,
189
- metavar="JSON_OR_@FILE",
190
- help="JSON object merged into extra keyword arguments.",
191
- )
192
- continue
193
-
194
- flag = f"--{parameter.name.replace('_', '-')}"
195
- if flag in method_parser._option_string_actions:
196
- continue
197
- kwargs = _argument_kwargs(parameter)
198
- kwargs["dest"] = parameter.name
199
- kwargs["required"] = parameter.default is inspect.Signature.empty
200
- method_parser.add_argument(flag, **kwargs)
201
-
202
- method_parser.set_defaults(resource_name=resource_name, method_name=method_name)
203
-
204
- return parser
205
-
206
-
207
- def _collect_kwargs(args: argparse.Namespace, method: Any) -> dict[str, Any]:
208
- signature = inspect.signature(method)
209
- kwargs: dict[str, Any] = {}
210
- for parameter in signature.parameters.values():
211
- if parameter.name == "self":
212
- continue
213
- if parameter.kind == inspect.Parameter.VAR_KEYWORD:
214
- extra_kwargs: dict[str, Any] = {}
215
- if getattr(args, "extra_json", None):
216
- raw_extra = _load_json_value(args.extra_json)
217
- if not isinstance(raw_extra, dict):
218
- raise ValueError("--set-json must decode to a JSON object.")
219
- extra_kwargs.update(raw_extra)
220
- for item in getattr(args, "extra_items", []) or []:
221
- key, value = _parse_key_value(item)
222
- extra_kwargs[key] = value
223
- kwargs.update(extra_kwargs)
224
- continue
225
-
226
- value = getattr(args, parameter.name)
227
- if value is None:
228
- if parameter.default is inspect.Signature.empty:
229
- raise ValueError(f"--{parameter.name.replace('_', '-')} is required.")
230
- continue
231
- kwargs[parameter.name] = value
232
- return kwargs
233
-
234
-
235
- def main(argv: Optional[Sequence[str]] = None) -> int:
236
- parser = build_parser()
237
- args = parser.parse_args(argv)
238
-
239
- try:
240
- with GitCode(
241
- api_key=args.api_key,
242
- owner=args.owner,
243
- repo=args.repo,
244
- base_url=args.base_url,
245
- timeout=args.timeout,
246
- ) as client:
247
- resource = getattr(client, args.resource_name)
248
- method = getattr(resource, args.method_name)
249
- result = method(**_collect_kwargs(args, method))
250
- except (GitCodeError, OSError, TypeError, ValueError) as exc: # pragma: no cover - integration style
251
- print(f"error: {exc}", file=sys.stderr)
252
- return 1
253
-
254
- _write_output(result, output_file=args.output_file, compact=args.compact)
255
- return 0
@@ -1 +0,0 @@
1
- 1.1.3
File without changes
File without changes