xray-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xray_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Xray Cloud CLI - command-line interface for Xray test management."""
2
+
3
+ __version__ = "0.1.0"
xray_cli/auth.py ADDED
@@ -0,0 +1,88 @@
1
+ """Token service for Xray Cloud API authentication."""
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+
7
+ from xray_cli.errors import AuthError, TransportError
8
+ from xray_cli import log
9
+
10
+
11
+ class TokenService:
12
+ """Acquires and caches Xray API tokens using Client ID and Secret."""
13
+
14
+ def __init__(self, auth_url, client_id, client_secret, timeout=30):
15
+ self._auth_url = auth_url
16
+ self._client_id = client_id
17
+ self._client_secret = client_secret
18
+ self._timeout = timeout
19
+ self._token = None
20
+
21
+ def get_token(self):
22
+ """Return a cached token, or acquire a new one."""
23
+ if self._token is not None:
24
+ return self._token
25
+ self._token = self._authenticate()
26
+ return self._token
27
+
28
+ def invalidate_token(self):
29
+ """Clear the cached token so the next call re-authenticates."""
30
+ self._token = None
31
+
32
+ def _authenticate(self):
33
+ """Exchange Client ID/Secret for an auth token."""
34
+ payload = json.dumps({
35
+ "client_id": self._client_id,
36
+ "client_secret": self._client_secret,
37
+ }).encode("utf-8")
38
+
39
+ req = urllib.request.Request(
40
+ self._auth_url,
41
+ data=payload,
42
+ headers={"Content-Type": "application/json"},
43
+ method="POST",
44
+ )
45
+
46
+ log.debug("Authenticating against %s", self._auth_url)
47
+
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
50
+ body = resp.read().decode("utf-8")
51
+ # The Xray auth endpoint returns the token as a bare JSON string
52
+ token = json.loads(body)
53
+ if isinstance(token, str):
54
+ log.debug("Token acquired successfully")
55
+ return token
56
+ # Some responses may wrap it differently
57
+ if isinstance(token, dict) and "token" in token:
58
+ log.debug("Token acquired successfully")
59
+ return token["token"]
60
+ raise AuthError(
61
+ "Unexpected auth response format",
62
+ detail=f"Response body: {body[:200]}",
63
+ )
64
+ except urllib.error.HTTPError as exc:
65
+ status = exc.code
66
+ try:
67
+ detail = exc.read().decode("utf-8", errors="replace")[:500]
68
+ except Exception:
69
+ detail = None
70
+ if status in (401, 403):
71
+ raise AuthError(
72
+ "Authentication failed: invalid Client ID or Client Secret",
73
+ detail=detail,
74
+ )
75
+ raise AuthError(
76
+ f"Authentication failed with HTTP {status}",
77
+ detail=detail,
78
+ )
79
+ except urllib.error.URLError as exc:
80
+ raise TransportError(
81
+ f"Cannot reach auth endpoint: {exc.reason}",
82
+ detail=str(exc),
83
+ )
84
+ except (OSError, ValueError) as exc:
85
+ raise TransportError(
86
+ f"Auth request failed: {exc}",
87
+ detail=str(exc),
88
+ )
xray_cli/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """CLI entry point and argparse command tree for xray-cli."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from xray_cli import __version__
7
+ from xray_cli.config import load_config
8
+ from xray_cli.errors import XrayCliError, EXIT_USAGE_ERROR
9
+ from xray_cli import log
10
+
11
+
12
+ def _build_parser():
13
+ """Build the top-level argument parser with global flags."""
14
+ parser = argparse.ArgumentParser(
15
+ prog="xray",
16
+ description="Command-line interface for Xray Cloud test management.",
17
+ )
18
+ parser.add_argument(
19
+ "--version", action="version", version=f"%(prog)s {__version__}"
20
+ )
21
+ parser.add_argument(
22
+ "--config", metavar="PATH", default=None,
23
+ help="Path to config file (default: auto-discover .xray-cli.ini)",
24
+ )
25
+ parser.add_argument(
26
+ "--json", action="store_true", default=False, dest="json_output",
27
+ help="Output in JSON format instead of human-readable text",
28
+ )
29
+ parser.add_argument(
30
+ "--verbose", "-v", action="store_true", default=False,
31
+ help="Enable verbose/debug output",
32
+ )
33
+ parser.add_argument(
34
+ "--quiet", "-q", action="store_true", default=False,
35
+ help="Suppress all non-error output",
36
+ )
37
+ parser.add_argument(
38
+ "--timeout", type=int, default=None, metavar="SECONDS",
39
+ help="Request timeout in seconds (default: from config or 30)",
40
+ )
41
+
42
+ subparsers = parser.add_subparsers(dest="command", title="commands")
43
+
44
+ # Register command families
45
+ from xray_cli.commands import execution, test, run, results
46
+ from xray_cli.commands import precondition, test_set, test_plan, folder
47
+ execution.register(subparsers)
48
+ test.register(subparsers)
49
+ run.register(subparsers)
50
+ results.register(subparsers)
51
+ precondition.register(subparsers)
52
+ test_set.register(subparsers)
53
+ test_plan.register(subparsers)
54
+ folder.register(subparsers)
55
+
56
+ return parser
57
+
58
+
59
+ def _collect_cli_overrides(args):
60
+ """Extract CLI-level overrides to pass into config loading."""
61
+ overrides = {}
62
+ if args.json_output:
63
+ overrides["output"] = "json"
64
+ if args.timeout is not None:
65
+ overrides["timeout_seconds"] = str(args.timeout)
66
+ return overrides
67
+
68
+
69
+ _GLOBAL_FLAGS = {
70
+ "--json": 0, # 0 = flag takes no argument
71
+ "--verbose": 0,
72
+ "-v": 0,
73
+ "--quiet": 0,
74
+ "-q": 0,
75
+ "--config": 1, # 1 = flag takes one argument
76
+ "--timeout": 1,
77
+ }
78
+
79
+
80
+ def _hoist_global_flags(argv):
81
+ """Move global flags that appear after a subcommand to the front.
82
+
83
+ argparse only recognises top-level flags when they precede the
84
+ subcommand. This helper lets users write them anywhere, e.g.
85
+ ``xray test get OTR-1 --json`` instead of ``xray --json test get OTR-1``.
86
+ """
87
+ hoisted = []
88
+ rest = []
89
+ i = 0
90
+ while i < len(argv):
91
+ token = argv[i]
92
+ if token in _GLOBAL_FLAGS:
93
+ n_args = _GLOBAL_FLAGS[token]
94
+ hoisted.append(token)
95
+ if n_args and i + 1 < len(argv):
96
+ i += 1
97
+ hoisted.append(argv[i])
98
+ else:
99
+ rest.append(token)
100
+ i += 1
101
+ return hoisted + rest
102
+
103
+
104
+ def main(argv=None):
105
+ """Main entry point for xray-cli."""
106
+ parser = _build_parser()
107
+ raw = argv if argv is not None else sys.argv[1:]
108
+ args = parser.parse_args(_hoist_global_flags(raw))
109
+
110
+ # Configure logging
111
+ log.configure_verbosity(verbose=args.verbose, quiet=args.quiet)
112
+
113
+ # If no command given, print help
114
+ if not args.command:
115
+ parser.print_help(sys.stderr)
116
+ return EXIT_USAGE_ERROR
117
+
118
+ # Load config
119
+ try:
120
+ cli_overrides = _collect_cli_overrides(args)
121
+ config = load_config(
122
+ explicit_path=args.config,
123
+ cli_overrides=cli_overrides,
124
+ )
125
+ log.debug("Config loaded: %s", config)
126
+ except XrayCliError as exc:
127
+ log.error("%s: %s", exc.category, exc)
128
+ if exc.detail:
129
+ log.debug("Detail: %s", exc.detail)
130
+ return exc.exit_code
131
+
132
+ # Dispatch to subcommand handler
133
+ handler = getattr(args, "handler", None)
134
+ if handler is None:
135
+ parser.print_help(sys.stderr)
136
+ return EXIT_USAGE_ERROR
137
+
138
+ try:
139
+ return handler(args, config)
140
+ except XrayCliError as exc:
141
+ log.error("%s: %s", exc.category, exc)
142
+ if exc.detail:
143
+ log.debug("Detail: %s", exc.detail)
144
+ return exc.exit_code
145
+
146
+
147
+ def cli():
148
+ """Console script entry point — calls main() and exits."""
149
+ sys.exit(main() or 0)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ cli()
File without changes
@@ -0,0 +1,23 @@
1
+ """Shared helpers for command handlers."""
2
+
3
+
4
+ def build_service(config):
5
+ """Build a fully wired XrayService from config."""
6
+ from xray_cli.auth import TokenService
7
+ from xray_cli.graphql_client import GraphQLClient
8
+ from xray_cli.rest_client import RestClient
9
+ from xray_cli.service import XrayService
10
+
11
+ token_service = TokenService(
12
+ config.auth_url, config.client_id, config.client_secret,
13
+ timeout=config.timeout_seconds,
14
+ )
15
+ gql_client = GraphQLClient(
16
+ config.graphql_url, token_service,
17
+ timeout=config.timeout_seconds,
18
+ )
19
+ rest_client = RestClient(
20
+ config.import_base_url, token_service,
21
+ timeout=config.timeout_seconds,
22
+ )
23
+ return XrayService(gql_client, rest_client=rest_client)
@@ -0,0 +1,97 @@
1
+ """Command handlers for execution operations."""
2
+
3
+ from xray_cli import formatter
4
+ from xray_cli.errors import ValidationError
5
+
6
+
7
+ def register(subparsers):
8
+ """Register execution subcommands."""
9
+ exec_parser = subparsers.add_parser("execution", help="Test execution operations")
10
+ exec_sub = exec_parser.add_subparsers(dest="execution_command", title="execution commands")
11
+
12
+ # execution list
13
+ list_parser = exec_sub.add_parser("list", help="List test executions")
14
+ list_parser.add_argument(
15
+ "--jql", default=None,
16
+ help="JQL filter for executions",
17
+ )
18
+ list_parser.add_argument(
19
+ "--limit", type=int, default=None,
20
+ help="Maximum results per page (1-100, default: from config)",
21
+ )
22
+ list_parser.add_argument(
23
+ "--start", type=int, default=0,
24
+ help="Start offset for pagination (default: 0)",
25
+ )
26
+ list_parser.set_defaults(handler=handle_execution_list)
27
+
28
+ # execution create
29
+ create_parser = exec_sub.add_parser("create", help="Create a test execution")
30
+ create_parser.add_argument(
31
+ "--project", required=True, dest="project_key",
32
+ help="Jira project key (e.g. XSP)",
33
+ )
34
+ create_parser.add_argument(
35
+ "--summary", required=True,
36
+ help="Summary for the new test execution",
37
+ )
38
+ create_parser.add_argument(
39
+ "--test", dest="test_keys", action="append", default=None,
40
+ help="Test issue key to include (repeatable)",
41
+ )
42
+ create_parser.set_defaults(handler=handle_execution_create)
43
+
44
+ # Set a fallback handler for bare "execution" with no subcommand
45
+ exec_parser.set_defaults(handler=lambda args, config: _show_help(exec_parser))
46
+
47
+
48
+ def _show_help(parser):
49
+ parser.print_help()
50
+ return 2
51
+
52
+
53
+ def _build_service(config):
54
+ from xray_cli.auth import TokenService
55
+ from xray_cli.graphql_client import GraphQLClient
56
+ from xray_cli.service import XrayService
57
+
58
+ token_service = TokenService(
59
+ config.auth_url, config.client_id, config.client_secret,
60
+ timeout=config.timeout_seconds,
61
+ )
62
+ gql_client = GraphQLClient(
63
+ config.graphql_url, token_service,
64
+ timeout=config.timeout_seconds,
65
+ )
66
+ return XrayService(gql_client)
67
+
68
+
69
+ def handle_execution_list(args, config):
70
+ """Handle 'execution list' command."""
71
+ limit = args.limit if args.limit is not None else config.page_limit
72
+ if limit < 1 or limit > 100:
73
+ raise ValidationError("--limit must be between 1 and 100")
74
+
75
+ service = _build_service(config)
76
+ result = service.list_executions(
77
+ jql=args.jql,
78
+ limit=limit,
79
+ start=args.start,
80
+ )
81
+
82
+ output_mode = "json" if args.json_output else config.output
83
+ formatter.render(result, mode=output_mode)
84
+ return 0
85
+
86
+
87
+ def handle_execution_create(args, config):
88
+ """Handle 'execution create' command."""
89
+ service = _build_service(config)
90
+ result = service.create_execution(
91
+ project_key=args.project_key,
92
+ summary=args.summary,
93
+ test_keys=args.test_keys,
94
+ )
95
+ output_mode = "json" if args.json_output else config.output
96
+ formatter.render(result, mode=output_mode)
97
+ return 0
@@ -0,0 +1,39 @@
1
+ """Command handlers for folder operations."""
2
+
3
+ from xray_cli import formatter
4
+ from xray_cli.errors import ValidationError
5
+
6
+
7
+ def register(subparsers):
8
+ """Register folder subcommands."""
9
+ parser = subparsers.add_parser("folder", help="Folder operations")
10
+ sub = parser.add_subparsers(dest="folder_command", title="folder commands")
11
+
12
+ list_parser = sub.add_parser("list", help="List folders")
13
+ list_parser.add_argument(
14
+ "--project", required=True, dest="project_key",
15
+ help="Jira project key (required)",
16
+ )
17
+ list_parser.add_argument("--path", default="/", help="Folder path (default: root /)")
18
+ list_parser.add_argument("--limit", type=int, default=None, help="Page size (1-100)")
19
+ list_parser.add_argument("--start", type=int, default=0, help="Pagination offset")
20
+ list_parser.set_defaults(handler=handle_list)
21
+
22
+ parser.set_defaults(handler=lambda args, config: (parser.print_help(), 2)[1])
23
+
24
+
25
+ def handle_list(args, config):
26
+ from xray_cli.commands._shared import build_service
27
+ limit = args.limit if args.limit is not None else config.page_limit
28
+ if limit < 1 or limit > 100:
29
+ raise ValidationError("--limit must be between 1 and 100")
30
+ service = build_service(config)
31
+ result = service.list_folders(
32
+ project_key=args.project_key,
33
+ path=args.path,
34
+ limit=limit,
35
+ start=args.start,
36
+ )
37
+ output_mode = "json" if args.json_output else config.output
38
+ formatter.render(result, mode=output_mode)
39
+ return 0
@@ -0,0 +1,30 @@
1
+ """Command handlers for precondition operations."""
2
+
3
+ from xray_cli import formatter
4
+ from xray_cli.errors import ValidationError
5
+
6
+
7
+ def register(subparsers):
8
+ """Register precondition subcommands."""
9
+ parser = subparsers.add_parser("precondition", help="Precondition operations")
10
+ sub = parser.add_subparsers(dest="precondition_command", title="precondition commands")
11
+
12
+ list_parser = sub.add_parser("list", help="List preconditions")
13
+ list_parser.add_argument("--jql", default=None, help="JQL filter")
14
+ list_parser.add_argument("--limit", type=int, default=None, help="Page size (1-100)")
15
+ list_parser.add_argument("--start", type=int, default=0, help="Pagination offset")
16
+ list_parser.set_defaults(handler=handle_list)
17
+
18
+ parser.set_defaults(handler=lambda args, config: (parser.print_help(), 2)[1])
19
+
20
+
21
+ def handle_list(args, config):
22
+ from xray_cli.commands._shared import build_service
23
+ limit = args.limit if args.limit is not None else config.page_limit
24
+ if limit < 1 or limit > 100:
25
+ raise ValidationError("--limit must be between 1 and 100")
26
+ service = build_service(config)
27
+ result = service.list_preconditions(jql=args.jql, limit=limit, start=args.start)
28
+ output_mode = "json" if args.json_output else config.output
29
+ formatter.render(result, mode=output_mode)
30
+ return 0
@@ -0,0 +1,82 @@
1
+ """Command handlers for results import operations."""
2
+
3
+ from xray_cli import formatter
4
+
5
+
6
+ def register(subparsers):
7
+ """Register results subcommands."""
8
+ results_parser = subparsers.add_parser("results", help="Test results operations")
9
+ results_sub = results_parser.add_subparsers(dest="results_command", title="results commands")
10
+
11
+ # results import
12
+ import_parser = results_sub.add_parser("import", help="Import test results")
13
+ import_sub = import_parser.add_subparsers(dest="import_format", title="import formats")
14
+
15
+ # results import xray-json
16
+ xray_json_parser = import_sub.add_parser("xray-json", help="Import Xray JSON results")
17
+ xray_json_parser.add_argument("file", help="Path to Xray JSON results file")
18
+ xray_json_parser.add_argument("--project", dest="project_key", default=None, help="Project key")
19
+ xray_json_parser.add_argument("--execution", dest="execution_key", default=None, help="Existing execution key")
20
+ xray_json_parser.set_defaults(handler=handle_import_xray_json)
21
+
22
+ # results import junit
23
+ junit_parser = import_sub.add_parser("junit", help="Import JUnit XML results")
24
+ junit_parser.add_argument("file", help="Path to JUnit XML results file")
25
+ junit_parser.add_argument("--project", dest="project_key", default=None, help="Project key")
26
+ junit_parser.add_argument("--execution", dest="execution_key", default=None, help="Existing execution key")
27
+ junit_parser.set_defaults(handler=handle_import_junit)
28
+
29
+ import_parser.set_defaults(handler=lambda args, config: _show_help(import_parser))
30
+ results_parser.set_defaults(handler=lambda args, config: _show_help(results_parser))
31
+
32
+
33
+ def _show_help(parser):
34
+ parser.print_help()
35
+ return 2
36
+
37
+
38
+ def _build_service(config):
39
+ from xray_cli.auth import TokenService
40
+ from xray_cli.graphql_client import GraphQLClient
41
+ from xray_cli.rest_client import RestClient
42
+ from xray_cli.service import XrayService
43
+
44
+ token_service = TokenService(
45
+ config.auth_url, config.client_id, config.client_secret,
46
+ timeout=config.timeout_seconds,
47
+ )
48
+ gql_client = GraphQLClient(
49
+ config.graphql_url, token_service,
50
+ timeout=config.timeout_seconds,
51
+ )
52
+ rest_client = RestClient(
53
+ config.import_base_url, token_service,
54
+ timeout=config.timeout_seconds,
55
+ )
56
+ return XrayService(gql_client, rest_client=rest_client)
57
+
58
+
59
+ def handle_import_xray_json(args, config):
60
+ """Handle 'results import xray-json <file>' command."""
61
+ service = _build_service(config)
62
+ result = service.import_xray_json(
63
+ args.file,
64
+ project_key=args.project_key or config.project_key,
65
+ execution_key=args.execution_key,
66
+ )
67
+ output_mode = "json" if args.json_output else config.output
68
+ formatter.render(result, mode=output_mode)
69
+ return 0
70
+
71
+
72
+ def handle_import_junit(args, config):
73
+ """Handle 'results import junit <file>' command."""
74
+ service = _build_service(config)
75
+ result = service.import_junit_xml(
76
+ args.file,
77
+ project_key=args.project_key or config.project_key,
78
+ execution_key=args.execution_key,
79
+ )
80
+ output_mode = "json" if args.json_output else config.output
81
+ formatter.render(result, mode=output_mode)
82
+ return 0
@@ -0,0 +1,111 @@
1
+ """Command handlers for run operations."""
2
+
3
+ from xray_cli import formatter
4
+ from xray_cli.errors import ValidationError
5
+
6
+
7
+ def register(subparsers):
8
+ """Register run subcommands."""
9
+ run_parser = subparsers.add_parser("run", help="Test run operations")
10
+ run_sub = run_parser.add_subparsers(dest="run_command", title="run commands")
11
+
12
+ # run list
13
+ list_parser = run_sub.add_parser("list", help="List test runs")
14
+ list_parser.add_argument(
15
+ "--test", dest="test_keys", action="append", default=None,
16
+ help="Filter by test issue key (repeatable)",
17
+ )
18
+ list_parser.add_argument(
19
+ "--execution", dest="execution_keys", action="append", default=None,
20
+ help="Filter by execution issue key (repeatable)",
21
+ )
22
+ list_parser.add_argument(
23
+ "--status", default=None,
24
+ help="Filter by run status name",
25
+ )
26
+ list_parser.add_argument(
27
+ "--failed-only", action="store_true", default=False,
28
+ help="Show only failed/aborted runs",
29
+ )
30
+ list_parser.add_argument(
31
+ "--limit", type=int, default=None,
32
+ help="Maximum results per page (1-100, default: from config)",
33
+ )
34
+ list_parser.add_argument(
35
+ "--start", type=int, default=0,
36
+ help="Start offset for pagination (default: 0)",
37
+ )
38
+ list_parser.set_defaults(handler=handle_run_list)
39
+
40
+ # run update-status
41
+ update_parser = run_sub.add_parser("update-status", help="Update a test run status")
42
+ update_parser.add_argument("run_id", help="Test run ID")
43
+ update_parser.add_argument("status", help="New status (e.g. PASS, FAIL, TODO)")
44
+ update_parser.set_defaults(handler=handle_run_update_status)
45
+
46
+ # run add-evidence
47
+ evidence_parser = run_sub.add_parser("add-evidence", help="Add evidence to a test run")
48
+ evidence_parser.add_argument("run_id", help="Test run ID")
49
+ evidence_parser.add_argument("file", help="Path to evidence file")
50
+ evidence_parser.set_defaults(handler=handle_run_add_evidence)
51
+
52
+ run_parser.set_defaults(handler=lambda args, config: _show_help(run_parser))
53
+
54
+
55
+ def _show_help(parser):
56
+ parser.print_help()
57
+ return 2
58
+
59
+
60
+ def _build_service(config):
61
+ from xray_cli.auth import TokenService
62
+ from xray_cli.graphql_client import GraphQLClient
63
+ from xray_cli.service import XrayService
64
+
65
+ token_service = TokenService(
66
+ config.auth_url, config.client_id, config.client_secret,
67
+ timeout=config.timeout_seconds,
68
+ )
69
+ gql_client = GraphQLClient(
70
+ config.graphql_url, token_service,
71
+ timeout=config.timeout_seconds,
72
+ )
73
+ return XrayService(gql_client)
74
+
75
+
76
+ def handle_run_list(args, config):
77
+ """Handle 'run list' command."""
78
+ limit = args.limit if args.limit is not None else config.page_limit
79
+ if limit < 1 or limit > 100:
80
+ raise ValidationError("--limit must be between 1 and 100")
81
+
82
+ service = _build_service(config)
83
+ result = service.list_runs(
84
+ test_keys=args.test_keys,
85
+ execution_keys=args.execution_keys,
86
+ status=args.status,
87
+ failed_only=args.failed_only,
88
+ limit=limit,
89
+ start=args.start,
90
+ )
91
+ output_mode = "json" if args.json_output else config.output
92
+ formatter.render(result, mode=output_mode)
93
+ return 0
94
+
95
+
96
+ def handle_run_update_status(args, config):
97
+ """Handle 'run update-status <run-id> <status>' command."""
98
+ service = _build_service(config)
99
+ result = service.update_run_status(args.run_id, args.status)
100
+ output_mode = "json" if args.json_output else config.output
101
+ formatter.render(result, mode=output_mode)
102
+ return 0
103
+
104
+
105
+ def handle_run_add_evidence(args, config):
106
+ """Handle 'run add-evidence <run-id> <file>' command."""
107
+ service = _build_service(config)
108
+ result = service.add_run_evidence(args.run_id, args.file)
109
+ output_mode = "json" if args.json_output else config.output
110
+ formatter.render(result, mode=output_mode)
111
+ return 0