jartify 0.1.1__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.
jartify/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
jartify/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from jartify.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
jartify/app.py ADDED
@@ -0,0 +1,221 @@
1
+ """`jartify-tui` — Textual TUI for interactive Artifactory searches.
2
+
3
+ Layout:
4
+
5
+ * Top bar: Input field for the name pattern.
6
+ * Centre: DataTable of hits (version / filename / repo / size).
7
+ * Bottom: Detail pane showing all checksums + download URL for the
8
+ highlighted row.
9
+
10
+ Bindings:
11
+
12
+ Enter run the search
13
+ r repeat last search
14
+ y copy sha256 of the highlighted row to the clipboard (if
15
+ `pyperclip` is importable; otherwise prints to the log)
16
+ q quit
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+
23
+ from textual import on, work
24
+ from textual.app import App, ComposeResult
25
+ from textual.binding import Binding
26
+ from textual.containers import Horizontal, Vertical
27
+ from textual.widgets import DataTable, Footer, Header, Input, Static
28
+
29
+ from jartify import __version__, config
30
+ from jartify.client import Artifact, ArtifactoryClient, ArtifactoryError
31
+
32
+
33
+ HELP = (
34
+ "Type a filename pattern (wildcards * and ? allowed) and press Enter. "
35
+ "Highlight a row to see full checksums below. y copies sha256."
36
+ )
37
+
38
+
39
+ class DetailPane(Static):
40
+ DEFAULT_CSS = """
41
+ DetailPane {
42
+ padding: 1 2;
43
+ border: tall $accent;
44
+ height: auto;
45
+ min-height: 9;
46
+ }
47
+ """
48
+
49
+ def show(self, art: Artifact | None) -> None:
50
+ if art is None:
51
+ self.update(HELP)
52
+ return
53
+ size = f"{art.size:,} bytes" if art.size is not None else "-"
54
+ self.update(
55
+ f"[b]{art.filename}[/b]\n"
56
+ f"repo: {art.repo}\n"
57
+ f"path: {art.path}\n"
58
+ f"version: {art.display_version}\n"
59
+ f"size: {size}\n"
60
+ f"sha256: {art.sha256 or '-'}\n"
61
+ f"sha1: {art.sha1 or '-'}\n"
62
+ f"md5: {art.md5 or '-'}\n"
63
+ f"url: {art.download_uri or '-'}"
64
+ )
65
+
66
+
67
+ class JartifyApp(App):
68
+ CSS = """
69
+ #search {
70
+ dock: top;
71
+ margin: 1 2 0 2;
72
+ }
73
+ DataTable {
74
+ height: 1fr;
75
+ margin: 1 2 0 2;
76
+ }
77
+ #status {
78
+ dock: bottom;
79
+ height: 1;
80
+ padding: 0 2;
81
+ color: $text-muted;
82
+ }
83
+ """
84
+
85
+ BINDINGS = [
86
+ Binding("ctrl+c,q", "quit", "Quit"),
87
+ Binding("r", "repeat", "Repeat search"),
88
+ Binding("y", "copy_sha", "Copy sha256"),
89
+ ]
90
+
91
+ def __init__(self, client: ArtifactoryClient, repos, max_results):
92
+ super().__init__()
93
+ self.title = f"jartify {__version__}"
94
+ self.client = client
95
+ self.repos = repos
96
+ self.max_results = max_results
97
+ self._artifacts: list[Artifact] = []
98
+ self._last_pattern: str = ""
99
+ self._search_lock = threading.Lock()
100
+
101
+ def compose(self) -> ComposeResult:
102
+ yield Header(show_clock=False)
103
+ yield Input(placeholder="filename pattern (e.g. requests-*.whl)", id="search")
104
+ yield DataTable(id="results", zebra_stripes=True, cursor_type="row")
105
+ yield Vertical(DetailPane(HELP, id="detail"))
106
+ yield Static("", id="status")
107
+ yield Footer()
108
+
109
+ def on_mount(self) -> None:
110
+ table = self.query_one("#results", DataTable)
111
+ table.add_columns("Version", "Filename", "Repo", "Size")
112
+ self.query_one("#search", Input).focus()
113
+
114
+ def _set_status(self, text: str) -> None:
115
+ self.query_one("#status", Static).update(text)
116
+
117
+ @on(Input.Submitted, "#search")
118
+ def _on_submit(self, event: Input.Submitted) -> None:
119
+ pattern = event.value.strip()
120
+ if not pattern:
121
+ return
122
+ self._last_pattern = pattern
123
+ self._run_search(pattern)
124
+
125
+ def action_repeat(self) -> None:
126
+ if self._last_pattern:
127
+ self._run_search(self._last_pattern)
128
+
129
+ def action_copy_sha(self) -> None:
130
+ art = self._selected_artifact()
131
+ if art is None or not art.sha256:
132
+ self._set_status("no sha256 to copy")
133
+ return
134
+ try:
135
+ import pyperclip # type: ignore
136
+
137
+ pyperclip.copy(art.sha256)
138
+ self._set_status(f"copied sha256 for {art.filename}")
139
+ except Exception:
140
+ self.log(f"sha256={art.sha256}")
141
+ self._set_status(f"pyperclip missing; sha256 printed to log")
142
+
143
+ @on(DataTable.RowHighlighted)
144
+ def _on_highlight(self, event: DataTable.RowHighlighted) -> None:
145
+ self.query_one("#detail", DetailPane).show(self._selected_artifact())
146
+
147
+ def _selected_artifact(self) -> Artifact | None:
148
+ table = self.query_one("#results", DataTable)
149
+ if not self._artifacts or table.cursor_row is None:
150
+ return None
151
+ if 0 <= table.cursor_row < len(self._artifacts):
152
+ return self._artifacts[table.cursor_row]
153
+ return None
154
+
155
+ @work(thread=True)
156
+ def _run_search(self, pattern: str) -> None:
157
+ # Only one search at a time.
158
+ if not self._search_lock.acquire(blocking=False):
159
+ return
160
+ try:
161
+ self.call_from_thread(self._set_status, f"searching for {pattern!r}…")
162
+ try:
163
+ hits = list(
164
+ self.client.iter_artifacts(
165
+ pattern, repos=self.repos, max_results=self.max_results
166
+ )
167
+ )
168
+ except ArtifactoryError as exc:
169
+ self.call_from_thread(self._set_status, f"error: {exc}")
170
+ return
171
+ self.call_from_thread(self._populate, pattern, hits)
172
+ finally:
173
+ self._search_lock.release()
174
+
175
+ def _populate(self, pattern: str, hits: list[Artifact]) -> None:
176
+ table = self.query_one("#results", DataTable)
177
+ table.clear()
178
+ self._artifacts = hits
179
+ for a in hits:
180
+ size = f"{a.size:,}" if a.size is not None else "-"
181
+ table.add_row(a.display_version, a.filename, a.repo, size)
182
+ self._set_status(f"{len(hits)} hit(s) for {pattern!r}")
183
+ if hits:
184
+ self.query_one("#detail", DetailPane).show(hits[0])
185
+ else:
186
+ self.query_one("#detail", DetailPane).show(None)
187
+
188
+
189
+ def main() -> int:
190
+ state = config.bootstrap()
191
+ if state != "ok":
192
+ print(config.USAGE)
193
+ if state == "created":
194
+ print(f"Wrote template at {config.CONFIG_PATH}. Edit it, then re-run.")
195
+ return 2
196
+
197
+ cfg = config.load() or {}
198
+ try:
199
+ auth, headers = config.resolve_auth(cfg)
200
+ except RuntimeError as exc:
201
+ print(f"error: {exc}")
202
+ return 2
203
+
204
+ settings = cfg.get("settings") or {}
205
+ base_url = (cfg.get("artifactory") or {}).get("url")
206
+ client = ArtifactoryClient(
207
+ base_url,
208
+ auth=auth,
209
+ headers=headers,
210
+ timeout=int(settings.get("timeout") or 30),
211
+ )
212
+ repos_raw = (cfg.get("artifactory") or {}).get("repos") or ""
213
+ repos = [r.strip() for r in repos_raw.split(",") if r.strip()] or None
214
+ max_results = int(settings.get("max_results") or 200)
215
+
216
+ JartifyApp(client, repos, max_results).run()
217
+ return 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())
jartify/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """`jartify` CLI entry point.
2
+
3
+ Given a filename or substring/wildcard pattern, prints one row per
4
+ matching artifact: version, filename, sha256.
5
+
6
+ Default output is a human-readable aligned table. Use ``--json`` for
7
+ machine-readable output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from jartify import __version__, config
17
+ from jartify.client import ArtifactoryClient, ArtifactoryError
18
+
19
+
20
+ def _build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(
22
+ prog="jartify",
23
+ description=(
24
+ "Search Artifactory for a file and print version, filename, "
25
+ "and sha256 checksum."
26
+ ),
27
+ )
28
+ p.add_argument(
29
+ "pattern",
30
+ nargs="?",
31
+ help="Filename or wildcard pattern (e.g. 'requests-*.whl').",
32
+ )
33
+ p.add_argument(
34
+ "--repo",
35
+ action="append",
36
+ default=None,
37
+ help="Restrict to a repo (repeatable). Overrides config repos list.",
38
+ )
39
+ p.add_argument(
40
+ "--max",
41
+ type=int,
42
+ default=None,
43
+ help="Max number of results (defaults to settings.max_results).",
44
+ )
45
+ p.add_argument("--json", action="store_true", help="Emit JSON instead of a table.")
46
+ p.add_argument(
47
+ "--configure",
48
+ action="store_true",
49
+ help="Run interactive configuration and exit.",
50
+ )
51
+ p.add_argument("--version", action="version", version=f"jartify {__version__}")
52
+ return p
53
+
54
+
55
+ def _print_table(rows: list[dict]) -> None:
56
+ if not rows:
57
+ print("No matches.")
58
+ return
59
+ headers = ("VERSION", "FILENAME", "SHA256")
60
+ widths = [
61
+ max(len(headers[0]), *(len(r["version"]) for r in rows)),
62
+ max(len(headers[1]), *(len(r["filename"]) for r in rows)),
63
+ max(len(headers[2]), *(len(r["sha256"]) for r in rows)),
64
+ ]
65
+ fmt = f"{{:<{widths[0]}}} {{:<{widths[1]}}} {{:<{widths[2]}}}"
66
+ print(fmt.format(*headers))
67
+ print(fmt.format("-" * widths[0], "-" * widths[1], "-" * widths[2]))
68
+ for r in rows:
69
+ print(fmt.format(r["version"], r["filename"], r["sha256"]))
70
+
71
+
72
+ def _resolve_repos(args, cfg) -> list[str] | None:
73
+ if args.repo:
74
+ return args.repo
75
+ raw = (cfg.get("artifactory") or {}).get("repos") or ""
76
+ repos = [r.strip() for r in raw.split(",") if r.strip()]
77
+ return repos or None
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ args = _build_parser().parse_args(argv)
82
+
83
+ if args.configure:
84
+ config.interactive_configure()
85
+ return 0
86
+
87
+ state = config.bootstrap()
88
+ if state != "ok":
89
+ print(config.USAGE)
90
+ if state == "created":
91
+ print(f"Wrote template at {config.CONFIG_PATH}. Edit it, then re-run.")
92
+ return 2
93
+
94
+ if not args.pattern:
95
+ _build_parser().print_help()
96
+ return 2
97
+
98
+ cfg = config.load() or {}
99
+ try:
100
+ auth, headers = config.resolve_auth(cfg)
101
+ except RuntimeError as exc:
102
+ print(f"error: {exc}", file=sys.stderr)
103
+ return 2
104
+
105
+ settings = cfg.get("settings") or {}
106
+ base_url = (cfg.get("artifactory") or {}).get("url")
107
+ client = ArtifactoryClient(
108
+ base_url,
109
+ auth=auth,
110
+ headers=headers,
111
+ timeout=int(settings.get("timeout") or 30),
112
+ )
113
+
114
+ max_results = args.max or int(settings.get("max_results") or 200)
115
+ repos = _resolve_repos(args, cfg)
116
+
117
+ try:
118
+ artifacts = list(
119
+ client.iter_artifacts(args.pattern, repos=repos, max_results=max_results)
120
+ )
121
+ except ArtifactoryError as exc:
122
+ print(f"artifactory error: {exc}", file=sys.stderr)
123
+ return 1
124
+
125
+ rows = [
126
+ {
127
+ "version": a.display_version,
128
+ "filename": a.filename,
129
+ "sha256": a.sha256 or "-",
130
+ "repo": a.repo,
131
+ "path": a.path,
132
+ "size": a.size,
133
+ "download_uri": a.download_uri,
134
+ }
135
+ for a in artifacts
136
+ ]
137
+
138
+ if args.json:
139
+ print(json.dumps(rows, indent=2))
140
+ else:
141
+ _print_table(rows)
142
+
143
+ return 0 if rows else 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
jartify/client.py ADDED
@@ -0,0 +1,181 @@
1
+ """Artifactory HTTP client.
2
+
3
+ Thin wrapper around the Artifactory REST API covering the two calls
4
+ jartify needs:
5
+
6
+ * ``search_artifacts`` -> ``/api/search/artifact?name=...``
7
+ * ``get_file_info`` -> ``/api/storage/<repo>/<path>``
8
+
9
+ The storage endpoint returns the checksum block (md5/sha1/sha256) we
10
+ report to the user.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import Iterable, Iterator
18
+ from urllib.parse import urljoin
19
+
20
+ import requests
21
+ from packaging.utils import parse_sdist_filename, parse_wheel_filename
22
+
23
+
24
+ @dataclass
25
+ class Artifact:
26
+ repo: str
27
+ path: str # repo-relative path including filename
28
+ filename: str
29
+ version: str | None # parsed from filename when possible
30
+ size: int | None
31
+ sha256: str | None
32
+ sha1: str | None
33
+ md5: str | None
34
+ download_uri: str | None
35
+
36
+ @property
37
+ def display_version(self) -> str:
38
+ return self.version or "-"
39
+
40
+
41
+ _VERSION_RE = re.compile(r"-(\d[\w.+!-]*?)(?:[-.](?:py|cp|pp|jy|ip)\d|\.(?:tar|zip|whl|egg|jar|war|pom|rpm|deb)\b)")
42
+
43
+
44
+ def _parse_version(filename: str) -> str | None:
45
+ """Best-effort version extraction from an artifact filename.
46
+
47
+ Handles wheels and sdists via the ``packaging`` library (PEP 427 /
48
+ PEP 625) and falls back to a lenient regex for other artifact
49
+ types (jars, rpms, raw archives, etc.).
50
+ """
51
+ lower = filename.lower()
52
+ try:
53
+ if lower.endswith(".whl"):
54
+ _, version, *_ = parse_wheel_filename(filename)
55
+ return str(version)
56
+ if lower.endswith((".tar.gz", ".zip")):
57
+ _, version = parse_sdist_filename(filename)
58
+ return str(version)
59
+ except Exception:
60
+ pass
61
+
62
+ m = _VERSION_RE.search(filename)
63
+ return m.group(1) if m else None
64
+
65
+
66
+ class ArtifactoryError(RuntimeError):
67
+ pass
68
+
69
+
70
+ class ArtifactoryClient:
71
+ def __init__(
72
+ self,
73
+ base_url: str,
74
+ *,
75
+ auth=None,
76
+ headers: dict | None = None,
77
+ timeout: int = 30,
78
+ session: requests.Session | None = None,
79
+ ):
80
+ if not base_url:
81
+ raise ValueError("base_url is required")
82
+ self.base_url = base_url.rstrip("/")
83
+ if not self.base_url.endswith("/artifactory"):
84
+ self.base_url = self.base_url + "/artifactory"
85
+ self.timeout = timeout
86
+ self.session = session or requests.Session()
87
+ if auth is not None:
88
+ self.session.auth = auth
89
+ if headers:
90
+ self.session.headers.update(headers)
91
+ self.session.headers.setdefault("Accept", "application/json")
92
+
93
+ def _url(self, path: str) -> str:
94
+ return urljoin(self.base_url + "/", path.lstrip("/"))
95
+
96
+ def _get(self, path: str, **kwargs) -> dict:
97
+ resp = self.session.get(self._url(path), timeout=self.timeout, **kwargs)
98
+ if resp.status_code == 401:
99
+ raise ArtifactoryError("401 Unauthorized — check your token / username.")
100
+ if resp.status_code == 403:
101
+ raise ArtifactoryError("403 Forbidden — token lacks read permission.")
102
+ if resp.status_code >= 400:
103
+ raise ArtifactoryError(f"{resp.status_code} {resp.reason}: {resp.text[:300]}")
104
+ return resp.json()
105
+
106
+ def search_artifacts(
107
+ self,
108
+ name_pattern: str,
109
+ *,
110
+ repos: Iterable[str] | None = None,
111
+ max_results: int | None = None,
112
+ ) -> list[str]:
113
+ """Return a list of storage URIs matching the given name pattern.
114
+
115
+ ``name_pattern`` is matched by Artifactory using substring +
116
+ wildcard semantics (``*`` and ``?``).
117
+ """
118
+ params: dict = {"name": name_pattern}
119
+ if repos:
120
+ params["repos"] = ",".join(r for r in repos if r)
121
+ data = self._get("api/search/artifact", params=params)
122
+ uris = [row["uri"] for row in data.get("results", []) if "uri" in row]
123
+ if max_results is not None:
124
+ uris = uris[:max_results]
125
+ return uris
126
+
127
+ def get_file_info(self, storage_uri: str) -> Artifact | None:
128
+ """Fetch the storage info block for one URI and return an Artifact.
129
+
130
+ ``storage_uri`` is a full URL as returned by Artifactory's search
131
+ endpoints (``.../api/storage/<repo>/<path>``). Folders have no
132
+ ``checksums`` field and are skipped (returns None).
133
+ """
134
+ resp = self.session.get(storage_uri, timeout=self.timeout)
135
+ if resp.status_code >= 400:
136
+ raise ArtifactoryError(
137
+ f"{resp.status_code} fetching {storage_uri}: {resp.text[:200]}"
138
+ )
139
+ info = resp.json()
140
+ if "checksums" not in info:
141
+ return None # directory, not a file
142
+
143
+ repo = info.get("repo", "")
144
+ rel_path = info.get("path", "").lstrip("/")
145
+ filename = rel_path.rsplit("/", 1)[-1]
146
+ checksums = info.get("checksums") or {}
147
+ size = info.get("size")
148
+ try:
149
+ size_int = int(size) if size is not None else None
150
+ except (TypeError, ValueError):
151
+ size_int = None
152
+
153
+ return Artifact(
154
+ repo=repo,
155
+ path=rel_path,
156
+ filename=filename,
157
+ version=_parse_version(filename),
158
+ size=size_int,
159
+ sha256=checksums.get("sha256"),
160
+ sha1=checksums.get("sha1"),
161
+ md5=checksums.get("md5"),
162
+ download_uri=info.get("downloadUri"),
163
+ )
164
+
165
+ def iter_artifacts(
166
+ self,
167
+ name_pattern: str,
168
+ *,
169
+ repos: Iterable[str] | None = None,
170
+ max_results: int | None = None,
171
+ ) -> Iterator[Artifact]:
172
+ """Convenience: search + fetch each hit's file info, yielding Artifacts."""
173
+ for uri in self.search_artifacts(
174
+ name_pattern, repos=repos, max_results=max_results
175
+ ):
176
+ try:
177
+ art = self.get_file_info(uri)
178
+ except ArtifactoryError:
179
+ continue
180
+ if art is not None:
181
+ yield art
jartify/config.py ADDED
@@ -0,0 +1,137 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+
6
+
7
+ CONFIG_DIR = Path.home() / ".local" / "etc" / "jartify"
8
+ CONFIG_PATH = CONFIG_DIR / "config.yaml"
9
+
10
+
11
+ TEMPLATE = """\
12
+ artifactory:
13
+ url: https://artifactory.example.com
14
+ # Optional: restrict searches to a comma-separated list of repos.
15
+ # Leave empty to search across all repos the token can see.
16
+ repos: ""
17
+
18
+ authentication:
19
+ # Env var holding your Artifactory identity token or API key.
20
+ token_env: ARTIFACTORY_TOKEN
21
+ # Optional username for basic auth. If unset, the token is sent as a
22
+ # bearer token (identity token) and no username is needed.
23
+ # username: your.name
24
+
25
+ settings:
26
+ timeout: 30
27
+ retries: 3
28
+ # Max number of search hits to show. Artifactory itself caps this at
29
+ # whatever is configured server-side; this is a client-side guard.
30
+ max_results: 200
31
+ """
32
+
33
+
34
+ PLACEHOLDERS = {
35
+ ("artifactory", "url"): "https://artifactory.example.com",
36
+ }
37
+
38
+
39
+ USAGE = f"""\
40
+ jartify configuration
41
+ =====================
42
+
43
+ Config file: {CONFIG_PATH}
44
+
45
+ Required fields:
46
+ artifactory.url Base URL of your Artifactory instance
47
+ (without /artifactory suffix)
48
+
49
+ Authentication:
50
+ Set your Artifactory identity token in the env var named by
51
+ authentication.token_env (default: ARTIFACTORY_TOKEN):
52
+
53
+ export ARTIFACTORY_TOKEN=your_token_here
54
+
55
+ Edit the file above, then re-run: jartify <pattern> (or jartify-tui)
56
+ """
57
+
58
+
59
+ def write_template():
60
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
61
+ CONFIG_PATH.write_text(TEMPLATE)
62
+
63
+
64
+ def load():
65
+ if not CONFIG_PATH.exists():
66
+ return None
67
+ with CONFIG_PATH.open() as f:
68
+ return yaml.safe_load(f) or {}
69
+
70
+
71
+ def _get(cfg, path):
72
+ node = cfg
73
+ for key in path:
74
+ if not isinstance(node, dict):
75
+ return None
76
+ node = node.get(key)
77
+ return node
78
+
79
+
80
+ def is_configured(cfg):
81
+ for path, placeholder in PLACEHOLDERS.items():
82
+ if _get(cfg, path) == placeholder:
83
+ return False
84
+ if not _get(cfg, path):
85
+ return False
86
+ return True
87
+
88
+
89
+ def interactive_configure():
90
+ print("Let's configure jartify.\n")
91
+
92
+ url = input("Artifactory URL (e.g. https://artifactory.example.com): ").strip()
93
+ repos = input("Repos to search (comma-separated, blank for all): ").strip()
94
+ token_env = input(
95
+ "Env var holding your identity token [ARTIFACTORY_TOKEN]: "
96
+ ).strip() or "ARTIFACTORY_TOKEN"
97
+
98
+ content = TEMPLATE
99
+ content = content.replace("https://artifactory.example.com", url)
100
+ content = content.replace('repos: ""', f'repos: "{repos}"')
101
+ content = content.replace(
102
+ "token_env: ARTIFACTORY_TOKEN",
103
+ f"token_env: {token_env}",
104
+ )
105
+
106
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
107
+ CONFIG_PATH.write_text(content)
108
+ print(f"\nWrote {CONFIG_PATH}")
109
+
110
+ if not os.getenv(token_env):
111
+ print(f"\nRemember to set {token_env} in your shell:")
112
+ print(f" export {token_env}=your_token_here")
113
+
114
+
115
+ def bootstrap():
116
+ """Return 'created' / 'unconfigured' / 'ok'."""
117
+ if not CONFIG_PATH.exists():
118
+ write_template()
119
+ return "created"
120
+ cfg = load()
121
+ if not is_configured(cfg):
122
+ return "unconfigured"
123
+ return "ok"
124
+
125
+
126
+ def resolve_auth(cfg):
127
+ """Return (auth, headers) tuple for requests. auth is None for token auth."""
128
+ token_env = _get(cfg, ("authentication", "token_env")) or "ARTIFACTORY_TOKEN"
129
+ username = _get(cfg, ("authentication", "username"))
130
+ token = os.getenv(token_env)
131
+ if not token:
132
+ raise RuntimeError(
133
+ f"Env var {token_env} is not set. Export your Artifactory token first."
134
+ )
135
+ if username:
136
+ return (username, token), {}
137
+ return None, {"Authorization": f"Bearer {token}"}
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: jartify
3
+ Version: 0.1.1
4
+ Summary: TUI and CLI for searching JFrog Artifactory artifacts (version, filename, sha256)
5
+ Author-email: Jakob Holst <code@jakobholst.dk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://gitlab.com/jakobholst/jartify
8
+ Project-URL: Repository, https://gitlab.com/jakobholst/jartify
9
+ Keywords: artifactory,jfrog,tui,textual,sha256,search
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development
18
+ Classifier: Topic :: System :: Archiving :: Packaging
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: requests>=2.28
23
+ Requires-Dist: textual>=0.50
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: packaging>=23.0
26
+
27
+ # jartify
28
+
29
+ `jartify` is a small toolkit for querying a JFrog Artifactory instance
30
+ from a terminal. It exposes two entry points:
31
+
32
+ | Command | Purpose |
33
+ |-----------------|--------------------------------------------------------------|
34
+ | `jartify` | One-shot CLI: find a file, print version / filename / sha256 |
35
+ | `jartify-tui` | Textual TUI for interactive searching |
36
+
37
+ Both share the same `~/.local/etc/jartify/config.yaml` and
38
+ `$ARTIFACTORY_TOKEN` env var (name is configurable).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install jartify
44
+ ```
45
+
46
+ ## Configure
47
+
48
+ The first run writes a template config:
49
+
50
+ ```bash
51
+ jartify --configure # interactive
52
+ # or just:
53
+ jartify foo # writes a template, prints path, exits
54
+ ```
55
+
56
+ Edit `~/.local/etc/jartify/config.yaml`:
57
+
58
+ ```yaml
59
+ artifactory:
60
+ url: https://artifactory.example.com
61
+ repos: "" # optional comma-separated allowlist
62
+
63
+ authentication:
64
+ token_env: ARTIFACTORY_TOKEN # name of env var holding your token
65
+ # username: your.name # uncomment for basic auth instead of bearer
66
+ ```
67
+
68
+ Export your token:
69
+
70
+ ```bash
71
+ export ARTIFACTORY_TOKEN=...
72
+ ```
73
+
74
+ ## CLI usage
75
+
76
+ ```bash
77
+ # Find all requests wheels and print version / filename / sha256:
78
+ jartify 'requests-*.whl'
79
+
80
+ # Restrict to specific repos:
81
+ jartify 'myapp-*.tar.gz' --repo pypi-local --repo pypi-releases
82
+
83
+ # Machine-readable output:
84
+ jartify 'myapp-*.whl' --json
85
+ ```
86
+
87
+ Output:
88
+
89
+ ```
90
+ VERSION FILENAME SHA256
91
+ ------- ------------------------------------- ----------------------------------------------------------------
92
+ 2.31.0 requests-2.31.0-py3-none-any.whl 4c491f82b8a8b4...
93
+ 2.32.3 requests-2.32.3-py3-none-any.whl 70761cfe03c773...
94
+ ```
95
+
96
+ Exit codes: `0` on at least one hit, `1` on no hits, `2` on config / auth errors.
97
+
98
+ ## TUI usage
99
+
100
+ ```bash
101
+ jartify-tui
102
+ ```
103
+
104
+ Keys: type pattern → <kbd>Enter</kbd>, <kbd>r</kbd> repeats the last
105
+ search, <kbd>y</kbd> copies the highlighted row's sha256,
106
+ <kbd>q</kbd> quits.
107
+
108
+ ## Releasing
109
+
110
+ ### One-time: register the PyPI trusted publisher
111
+
112
+ PyPI has no API for this — the form is behind your login + 2FA. The
113
+ helper script opens the right page and prints the values to type:
114
+
115
+ ```bash
116
+ python scripts/register-pypi-publisher.py
117
+ ```
118
+
119
+ It reads the project name from `pyproject.toml` and the GitLab
120
+ namespace/repo from `git remote get-url origin`, so there's nothing to
121
+ look up. In PyPI's "Add a new pending publisher" form, pick the GitLab
122
+ tab and paste the printed values.
123
+
124
+ ### Each release
125
+
126
+ Push to `main` with a bumped `version` in `pyproject.toml`. GitLab CI
127
+ runs `python -m build`, mints a short-lived PyPI token via OIDC
128
+ trusted publishing, and uploads with `twine upload --skip-existing`.
129
+ Pushes that do not bump the version are no-ops.
@@ -0,0 +1,11 @@
1
+ jartify/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ jartify/__main__.py,sha256=8ISVZj6Z_1V5G7deRmvCWZChtyfz64hjYRbnDjB5fxk,68
3
+ jartify/app.py,sha256=xBOG2Y5kz0ykT_-o4c0WouBtpxK462q9LyhJMUlKRos,6993
4
+ jartify/cli.py,sha256=ezhX_blxtnBhYp20aIUJAoS0dOVvXqY4gGmDtd-AAy8,4090
5
+ jartify/client.py,sha256=xM4bwSm6b6wfiAkvB2Yteuq1SzNTgeJPTcjNSPau3m0,5984
6
+ jartify/config.py,sha256=zaZpUITHglOkoxJuCk0cPvSO9cHdPWkqW-uQOoWqaQw,3726
7
+ jartify-0.1.1.dist-info/METADATA,sha256=9YFCzeXw5LVSA9odEJvtiiRJrVAicIX-g3Ee9SUvt70,3862
8
+ jartify-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ jartify-0.1.1.dist-info/entry_points.txt,sha256=0__k0dMkyGyriV8fKWs1RMzRrzlocK43cQTHM8BIHCc,76
10
+ jartify-0.1.1.dist-info/top_level.txt,sha256=7UBHvm5EM_GZfaH4Vj8zhsv9LL7yF3LdBVIEFR97P0Q,8
11
+ jartify-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ jartify = jartify.cli:main
3
+ jartify-tui = jartify.app:main
@@ -0,0 +1 @@
1
+ jartify