jartify 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jartify-0.1.1/PKG-INFO +129 -0
- jartify-0.1.1/README.md +103 -0
- jartify-0.1.1/pyproject.toml +44 -0
- jartify-0.1.1/setup.cfg +4 -0
- jartify-0.1.1/src/jartify/__init__.py +1 -0
- jartify-0.1.1/src/jartify/__main__.py +4 -0
- jartify-0.1.1/src/jartify/app.py +221 -0
- jartify-0.1.1/src/jartify/cli.py +147 -0
- jartify-0.1.1/src/jartify/client.py +181 -0
- jartify-0.1.1/src/jartify/config.py +137 -0
- jartify-0.1.1/src/jartify.egg-info/PKG-INFO +129 -0
- jartify-0.1.1/src/jartify.egg-info/SOURCES.txt +14 -0
- jartify-0.1.1/src/jartify.egg-info/dependency_links.txt +1 -0
- jartify-0.1.1/src/jartify.egg-info/entry_points.txt +3 -0
- jartify-0.1.1/src/jartify.egg-info/requires.txt +4 -0
- jartify-0.1.1/src/jartify.egg-info/top_level.txt +1 -0
jartify-0.1.1/PKG-INFO
ADDED
|
@@ -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.
|
jartify-0.1.1/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# jartify
|
|
2
|
+
|
|
3
|
+
`jartify` is a small toolkit for querying a JFrog Artifactory instance
|
|
4
|
+
from a terminal. It exposes two entry points:
|
|
5
|
+
|
|
6
|
+
| Command | Purpose |
|
|
7
|
+
|-----------------|--------------------------------------------------------------|
|
|
8
|
+
| `jartify` | One-shot CLI: find a file, print version / filename / sha256 |
|
|
9
|
+
| `jartify-tui` | Textual TUI for interactive searching |
|
|
10
|
+
|
|
11
|
+
Both share the same `~/.local/etc/jartify/config.yaml` and
|
|
12
|
+
`$ARTIFACTORY_TOKEN` env var (name is configurable).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install jartify
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configure
|
|
21
|
+
|
|
22
|
+
The first run writes a template config:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
jartify --configure # interactive
|
|
26
|
+
# or just:
|
|
27
|
+
jartify foo # writes a template, prints path, exits
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Edit `~/.local/etc/jartify/config.yaml`:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
artifactory:
|
|
34
|
+
url: https://artifactory.example.com
|
|
35
|
+
repos: "" # optional comma-separated allowlist
|
|
36
|
+
|
|
37
|
+
authentication:
|
|
38
|
+
token_env: ARTIFACTORY_TOKEN # name of env var holding your token
|
|
39
|
+
# username: your.name # uncomment for basic auth instead of bearer
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Export your token:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export ARTIFACTORY_TOKEN=...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## CLI usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Find all requests wheels and print version / filename / sha256:
|
|
52
|
+
jartify 'requests-*.whl'
|
|
53
|
+
|
|
54
|
+
# Restrict to specific repos:
|
|
55
|
+
jartify 'myapp-*.tar.gz' --repo pypi-local --repo pypi-releases
|
|
56
|
+
|
|
57
|
+
# Machine-readable output:
|
|
58
|
+
jartify 'myapp-*.whl' --json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Output:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
VERSION FILENAME SHA256
|
|
65
|
+
------- ------------------------------------- ----------------------------------------------------------------
|
|
66
|
+
2.31.0 requests-2.31.0-py3-none-any.whl 4c491f82b8a8b4...
|
|
67
|
+
2.32.3 requests-2.32.3-py3-none-any.whl 70761cfe03c773...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Exit codes: `0` on at least one hit, `1` on no hits, `2` on config / auth errors.
|
|
71
|
+
|
|
72
|
+
## TUI usage
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
jartify-tui
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Keys: type pattern → <kbd>Enter</kbd>, <kbd>r</kbd> repeats the last
|
|
79
|
+
search, <kbd>y</kbd> copies the highlighted row's sha256,
|
|
80
|
+
<kbd>q</kbd> quits.
|
|
81
|
+
|
|
82
|
+
## Releasing
|
|
83
|
+
|
|
84
|
+
### One-time: register the PyPI trusted publisher
|
|
85
|
+
|
|
86
|
+
PyPI has no API for this — the form is behind your login + 2FA. The
|
|
87
|
+
helper script opens the right page and prints the values to type:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python scripts/register-pypi-publisher.py
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It reads the project name from `pyproject.toml` and the GitLab
|
|
94
|
+
namespace/repo from `git remote get-url origin`, so there's nothing to
|
|
95
|
+
look up. In PyPI's "Add a new pending publisher" form, pick the GitLab
|
|
96
|
+
tab and paste the printed values.
|
|
97
|
+
|
|
98
|
+
### Each release
|
|
99
|
+
|
|
100
|
+
Push to `main` with a bumped `version` in `pyproject.toml`. GitLab CI
|
|
101
|
+
runs `python -m build`, mints a short-lived PyPI token via OIDC
|
|
102
|
+
trusted publishing, and uploads with `twine upload --skip-existing`.
|
|
103
|
+
Pushes that do not bump the version are no-ops.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jartify"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "TUI and CLI for searching JFrog Artifactory artifacts (version, filename, sha256)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jakob Holst", email = "code@jakobholst.dk" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["artifactory", "jfrog", "tui", "textual", "sha256", "search"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: System Administrators",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Software Development",
|
|
25
|
+
"Topic :: System :: Archiving :: Packaging",
|
|
26
|
+
"Topic :: Utilities",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"requests>=2.28",
|
|
30
|
+
"textual>=0.50",
|
|
31
|
+
"pyyaml>=6.0",
|
|
32
|
+
"packaging>=23.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
jartify = "jartify.cli:main"
|
|
37
|
+
jartify-tui = "jartify.app:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://gitlab.com/jakobholst/jartify"
|
|
41
|
+
Repository = "https://gitlab.com/jakobholst/jartify"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
jartify-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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
|
|
@@ -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,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/jartify/__init__.py
|
|
4
|
+
src/jartify/__main__.py
|
|
5
|
+
src/jartify/app.py
|
|
6
|
+
src/jartify/cli.py
|
|
7
|
+
src/jartify/client.py
|
|
8
|
+
src/jartify/config.py
|
|
9
|
+
src/jartify.egg-info/PKG-INFO
|
|
10
|
+
src/jartify.egg-info/SOURCES.txt
|
|
11
|
+
src/jartify.egg-info/dependency_links.txt
|
|
12
|
+
src/jartify.egg-info/entry_points.txt
|
|
13
|
+
src/jartify.egg-info/requires.txt
|
|
14
|
+
src/jartify.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jartify
|