s3v 0.1.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.
- s3v-0.1.0/.gitignore +207 -0
- s3v-0.1.0/LICENSE +21 -0
- s3v-0.1.0/PKG-INFO +160 -0
- s3v-0.1.0/README.md +117 -0
- s3v-0.1.0/pyproject.toml +68 -0
- s3v-0.1.0/s3v/__about__.py +1 -0
- s3v-0.1.0/s3v/__init__.py +0 -0
- s3v-0.1.0/s3v/aws.py +314 -0
- s3v-0.1.0/s3v/cli/main.py +91 -0
- s3v-0.1.0/s3v/ls.py +38 -0
- s3v-0.1.0/s3v/misc.py +11 -0
- s3v-0.1.0/s3v/versions.py +183 -0
s3v-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
s3v-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yaroslav
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
s3v-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s3v
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convenient CLI tool to work with versioned (including Object Lock) S3 buckets (recover, undelete, etc.)
|
|
5
|
+
Project-URL: Homepage, https://github.com/yaroslaff/s3v
|
|
6
|
+
Project-URL: Issues, https://github.com/yaroslaff/s3v/issues
|
|
7
|
+
Author-email: Yaroslav Polyakov <yaroslaff@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Yaroslav
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: aws,bucket,cli,object lock,recover,retention,s3,undelete,versioned
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Requires-Python: >=3.8
|
|
40
|
+
Requires-Dist: boto3>=1.28.0
|
|
41
|
+
Requires-Dist: botocore>=1.31.0
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# s3v
|
|
45
|
+
Convinient CLI tool to work with versioned S3 buckets. Like `aws s3` or `aws s3api` but much easier to use.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
~~~
|
|
49
|
+
# recommended
|
|
50
|
+
pipx install s3v
|
|
51
|
+
|
|
52
|
+
# or from git
|
|
53
|
+
pipx install git+https://github.com/yaroslaff/s3v
|
|
54
|
+
~~~
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
s3v uses boto3, so configuration is same as for `aws` utility (same `~/.aws/` files or `AWS_` shell variables, and optional `--profile NAME` argument)
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
We upload three versions of same file, each one will overwrite old copy. In "ls" we see filename, last modification time, size of latest copy and number of versions in storage.
|
|
62
|
+
|
|
63
|
+
### Upload
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
$ echo 1 > test.txt
|
|
67
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
68
|
+
$ echo 2 > test.txt
|
|
69
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
70
|
+
$ echo 3 > test.txt
|
|
71
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
72
|
+
```
|
|
73
|
+
You can also give full target name like s3://stg-objectlock/s3v/test.txt. `s3://` prefix is optional ()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
### List
|
|
77
|
+
Now, list contents of s3v logical 'folder' (all objects with name starting with `s3v/`). If we give full name of object, ls will list all versions.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
$ s3v ls stg-objectlock/s3v
|
|
81
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v'
|
|
82
|
+
test.txt |2026-02-10 16:21:55| 2| 3|
|
|
83
|
+
|
|
84
|
+
$ s3v ls stg-objectlock/s3v/test.txt
|
|
85
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/test.txt'
|
|
86
|
+
Objects under prefix 's3v/test.txt':
|
|
87
|
+
s3v/test.txt
|
|
88
|
+
by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD 2 2026-02-10 16:21:30
|
|
89
|
+
LHK6nA8Ny5YHNh7TOoH94LDinqCH9Czt 2 2026-02-10 16:21:46
|
|
90
|
+
iIGXCsBmEKvBq7DhaP09DIzp3fLO9d1H 2 2026-02-10 16:21:55
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
### Downloading
|
|
95
|
+
|
|
96
|
+
`s3v cp` will download latest version of file.
|
|
97
|
+
```bash
|
|
98
|
+
$ s3v cp stg-objectlock/s3v/test.txt .
|
|
99
|
+
|
|
100
|
+
$ cat test.txt
|
|
101
|
+
3
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Give `-i VERSION` to download specific version.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
$ s3v cp stg-objectlock/s3v/test.txt . -s by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
108
|
+
Using version: by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
109
|
+
$ cat test.txt
|
|
110
|
+
1
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
### Delete and undelete
|
|
115
|
+
|
|
116
|
+
`s3v rm` deletes file. After deletion, `aws s3 ls` do not list file, but `s3v ls` still shows it with `[DEL]` tag.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
$ s3v rm stg-objectlock/s3v/test.txt
|
|
120
|
+
$ aws s3 ls stg-objectlock/s3v/
|
|
121
|
+
$ s3v ls stg-objectlock/s3v/
|
|
122
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/'
|
|
123
|
+
test.txt |2026-02-10 16:35:00| 2| 4| [DEL]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
If we will see versions for file, we will see special delete marker on S3 (which makes file to be logically 'deleted').
|
|
127
|
+
```bash
|
|
128
|
+
$ s3v ls stg-objectlock/s3v/test.txt
|
|
129
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/test.txt'
|
|
130
|
+
# Fetching version metadata for bucket: stg-objectlock...
|
|
131
|
+
# Fetched metadata for 215 versions from 153 object(s) in bucket 'stg-objectlock'
|
|
132
|
+
Objects under prefix 's3v/test.txt':
|
|
133
|
+
s3v/test.txt [deleted]
|
|
134
|
+
by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD 2 2026-02-10 16:21:30
|
|
135
|
+
LHK6nA8Ny5YHNh7TOoH94LDinqCH9Czt 2 2026-02-10 16:21:46
|
|
136
|
+
iIGXCsBmEKvBq7DhaP09DIzp3fLO9d1H 2 2026-02-10 16:21:55
|
|
137
|
+
odaXkvFlMoAWwDu_q.K3esuYdHjUpgMg [DELETED] 2026-02-10 16:40:33
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`s3v unrm` will remove "delete marker" and file (latest version) will be available again.
|
|
141
|
+
```bash
|
|
142
|
+
$ s3v unrm stg-objectlock/s3v/test.txt
|
|
143
|
+
|
|
144
|
+
$ s3v ls stg-objectlock/s3v/
|
|
145
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/'
|
|
146
|
+
test.txt |2026-02-10 16:35:00| 2| 3|
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Restore specific version
|
|
150
|
+
To recover specific version of file (make it to be current) use `s3v recover` (or just `s3v r`). Let's recover first version of file.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
$ s3v recover stg-objectlock/s3v/test.txt . -s by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
154
|
+
Successfully recovered version by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD as current version of s3://stg-objectlock/s3v/test.txt
|
|
155
|
+
|
|
156
|
+
$ s3v cp stg-objectlock/s3v/test.txt .
|
|
157
|
+
$ cat test.txt
|
|
158
|
+
1
|
|
159
|
+
```
|
|
160
|
+
|
s3v-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# s3v
|
|
2
|
+
Convinient CLI tool to work with versioned S3 buckets. Like `aws s3` or `aws s3api` but much easier to use.
|
|
3
|
+
|
|
4
|
+
## Installation
|
|
5
|
+
~~~
|
|
6
|
+
# recommended
|
|
7
|
+
pipx install s3v
|
|
8
|
+
|
|
9
|
+
# or from git
|
|
10
|
+
pipx install git+https://github.com/yaroslaff/s3v
|
|
11
|
+
~~~
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
s3v uses boto3, so configuration is same as for `aws` utility (same `~/.aws/` files or `AWS_` shell variables, and optional `--profile NAME` argument)
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
We upload three versions of same file, each one will overwrite old copy. In "ls" we see filename, last modification time, size of latest copy and number of versions in storage.
|
|
19
|
+
|
|
20
|
+
### Upload
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
$ echo 1 > test.txt
|
|
24
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
25
|
+
$ echo 2 > test.txt
|
|
26
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
27
|
+
$ echo 3 > test.txt
|
|
28
|
+
$ s3v cp test.txt s3://stg-objectlock/s3v/
|
|
29
|
+
```
|
|
30
|
+
You can also give full target name like s3://stg-objectlock/s3v/test.txt. `s3://` prefix is optional ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### List
|
|
34
|
+
Now, list contents of s3v logical 'folder' (all objects with name starting with `s3v/`). If we give full name of object, ls will list all versions.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
$ s3v ls stg-objectlock/s3v
|
|
38
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v'
|
|
39
|
+
test.txt |2026-02-10 16:21:55| 2| 3|
|
|
40
|
+
|
|
41
|
+
$ s3v ls stg-objectlock/s3v/test.txt
|
|
42
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/test.txt'
|
|
43
|
+
Objects under prefix 's3v/test.txt':
|
|
44
|
+
s3v/test.txt
|
|
45
|
+
by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD 2 2026-02-10 16:21:30
|
|
46
|
+
LHK6nA8Ny5YHNh7TOoH94LDinqCH9Czt 2 2026-02-10 16:21:46
|
|
47
|
+
iIGXCsBmEKvBq7DhaP09DIzp3fLO9d1H 2 2026-02-10 16:21:55
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
### Downloading
|
|
52
|
+
|
|
53
|
+
`s3v cp` will download latest version of file.
|
|
54
|
+
```bash
|
|
55
|
+
$ s3v cp stg-objectlock/s3v/test.txt .
|
|
56
|
+
|
|
57
|
+
$ cat test.txt
|
|
58
|
+
3
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Give `-i VERSION` to download specific version.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
$ s3v cp stg-objectlock/s3v/test.txt . -s by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
65
|
+
Using version: by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
66
|
+
$ cat test.txt
|
|
67
|
+
1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### Delete and undelete
|
|
72
|
+
|
|
73
|
+
`s3v rm` deletes file. After deletion, `aws s3 ls` do not list file, but `s3v ls` still shows it with `[DEL]` tag.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
$ s3v rm stg-objectlock/s3v/test.txt
|
|
77
|
+
$ aws s3 ls stg-objectlock/s3v/
|
|
78
|
+
$ s3v ls stg-objectlock/s3v/
|
|
79
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/'
|
|
80
|
+
test.txt |2026-02-10 16:35:00| 2| 4| [DEL]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If we will see versions for file, we will see special delete marker on S3 (which makes file to be logically 'deleted').
|
|
84
|
+
```bash
|
|
85
|
+
$ s3v ls stg-objectlock/s3v/test.txt
|
|
86
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/test.txt'
|
|
87
|
+
# Fetching version metadata for bucket: stg-objectlock...
|
|
88
|
+
# Fetched metadata for 215 versions from 153 object(s) in bucket 'stg-objectlock'
|
|
89
|
+
Objects under prefix 's3v/test.txt':
|
|
90
|
+
s3v/test.txt [deleted]
|
|
91
|
+
by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD 2 2026-02-10 16:21:30
|
|
92
|
+
LHK6nA8Ny5YHNh7TOoH94LDinqCH9Czt 2 2026-02-10 16:21:46
|
|
93
|
+
iIGXCsBmEKvBq7DhaP09DIzp3fLO9d1H 2 2026-02-10 16:21:55
|
|
94
|
+
odaXkvFlMoAWwDu_q.K3esuYdHjUpgMg [DELETED] 2026-02-10 16:40:33
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`s3v unrm` will remove "delete marker" and file (latest version) will be available again.
|
|
98
|
+
```bash
|
|
99
|
+
$ s3v unrm stg-objectlock/s3v/test.txt
|
|
100
|
+
|
|
101
|
+
$ s3v ls stg-objectlock/s3v/
|
|
102
|
+
Listing objects in bucket 'stg-objectlock' with prefix 's3v/'
|
|
103
|
+
test.txt |2026-02-10 16:35:00| 2| 3|
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Restore specific version
|
|
107
|
+
To recover specific version of file (make it to be current) use `s3v recover` (or just `s3v r`). Let's recover first version of file.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
$ s3v recover stg-objectlock/s3v/test.txt . -s by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD
|
|
111
|
+
Successfully recovered version by0fQCa9Jl7gFgl8vKEjaDvl8z3CSRnD as current version of s3://stg-objectlock/s3v/test.txt
|
|
112
|
+
|
|
113
|
+
$ s3v cp stg-objectlock/s3v/test.txt .
|
|
114
|
+
$ cat test.txt
|
|
115
|
+
1
|
|
116
|
+
```
|
|
117
|
+
|
s3v-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "s3v"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"aws", "s3", "versioned", "bucket", "object lock",
|
|
11
|
+
"retention", "undelete", "recover", "cli"
|
|
12
|
+
]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Yaroslav Polyakov", email = "yaroslaff@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
description = "Convenient CLI tool to work with versioned (including Object Lock) S3 buckets (recover, undelete, etc.)"
|
|
17
|
+
readme = "README.md"
|
|
18
|
+
requires-python = ">=3.8"
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"boto3>=1.28.0",
|
|
31
|
+
"botocore>=1.31.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/yaroslaff/s3v"
|
|
36
|
+
Issues = "https://github.com/yaroslaff/s3v/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
s3v = "s3v.cli.main:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
path = "s3v/__about__.py"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.envs]
|
|
45
|
+
py38 = { python = "3.8" }
|
|
46
|
+
py39 = { python = "3.9" }
|
|
47
|
+
py310 = { python = "3.10" }
|
|
48
|
+
py311 = { python = "3.11" }
|
|
49
|
+
py312 = { python = "3.12" }
|
|
50
|
+
|
|
51
|
+
[tool.hatch.envs.default]
|
|
52
|
+
dependencies = [
|
|
53
|
+
"coverage[toml]>=6.5",
|
|
54
|
+
"pytest",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.envs.default.scripts]
|
|
58
|
+
test = "pytest {args:tests}"
|
|
59
|
+
test-cov = "coverage run -m pytest {args:tests}"
|
|
60
|
+
cov-report = ["coverage report -m"]
|
|
61
|
+
cov = ["test-cov", "cov-report"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
exclude_lines = [
|
|
65
|
+
"no cov",
|
|
66
|
+
"if __name__ == '__main__':",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
s3v-0.1.0/s3v/aws.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from botocore.exceptions import NoCredentialsError, ClientError
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .versions import VersionsIndex, VersionedObject
|
|
9
|
+
from .misc import kmgt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_bucket_name(bucket: str) -> tuple:
|
|
13
|
+
"""Parse bucket name and optional prefix from s3:// or bucket/prefix format.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
(bucket_name, prefix) tuple
|
|
17
|
+
"""
|
|
18
|
+
# Remove s3:// prefix if present
|
|
19
|
+
bucket = bucket.replace("s3://", "")
|
|
20
|
+
|
|
21
|
+
# Split on first / to separate bucket from prefix
|
|
22
|
+
if "/" in bucket:
|
|
23
|
+
parts = bucket.split("/", 1)
|
|
24
|
+
return parts[0], parts[1]
|
|
25
|
+
|
|
26
|
+
# Remove trailing slash only if no prefix
|
|
27
|
+
return bucket.rstrip("/"), ""
|
|
28
|
+
|
|
29
|
+
def list_buckets(profile_name=None):
|
|
30
|
+
"""List all S3 buckets."""
|
|
31
|
+
try:
|
|
32
|
+
session = boto3.Session(profile_name=profile_name)
|
|
33
|
+
s3_client = session.client("s3")
|
|
34
|
+
|
|
35
|
+
response = s3_client.list_buckets()
|
|
36
|
+
buckets = response.get("Buckets", [])
|
|
37
|
+
|
|
38
|
+
if not buckets:
|
|
39
|
+
print("No buckets found", file=sys.stderr)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
print("Available S3 buckets:")
|
|
43
|
+
for bucket in sorted(buckets, key=lambda x: x["Name"]):
|
|
44
|
+
creation_date = bucket.get("CreationDate", "").isoformat() if bucket.get("CreationDate") else "Unknown"
|
|
45
|
+
print(f" {bucket['Name']:<50} {creation_date}")
|
|
46
|
+
|
|
47
|
+
except NoCredentialsError:
|
|
48
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
49
|
+
raise sys.exit(1)
|
|
50
|
+
except ClientError as e:
|
|
51
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
52
|
+
raise sys.exit(1)
|
|
53
|
+
|
|
54
|
+
def sync_versions(bucket: str, profile_name: str | None = None):
|
|
55
|
+
"""Internal function to sync versions from S3."""
|
|
56
|
+
cache_dir = Path.home() / ".cache" / "s3v" / bucket
|
|
57
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
vi = VersionsIndex(bucket)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
session = boto3.Session(profile_name=profile_name)
|
|
63
|
+
s3_client = session.client("s3")
|
|
64
|
+
|
|
65
|
+
print(f"# Fetching version metadata for bucket: {bucket}...")
|
|
66
|
+
|
|
67
|
+
paginator = s3_client.get_paginator("list_object_versions")
|
|
68
|
+
pages = paginator.paginate(Bucket=bucket)
|
|
69
|
+
|
|
70
|
+
version_count = 0
|
|
71
|
+
|
|
72
|
+
for page in pages:
|
|
73
|
+
for version in page.get("Versions", []):
|
|
74
|
+
version['ETag'] = version['ETag'].strip('"') # Remove quotes from ETag
|
|
75
|
+
key = version["Key"]
|
|
76
|
+
vo = vi[key] if key in vi else VersionedObject(key)
|
|
77
|
+
vo.add_version(version)
|
|
78
|
+
vi[key] = vo
|
|
79
|
+
version_count += 1
|
|
80
|
+
|
|
81
|
+
for marker in page.get("DeleteMarkers", []):
|
|
82
|
+
key = marker["Key"]
|
|
83
|
+
vo = vi[key] if key in vi else VersionedObject(key)
|
|
84
|
+
vo.add_delete_marker(marker)
|
|
85
|
+
vi[key] = vo
|
|
86
|
+
version_count += 1
|
|
87
|
+
|
|
88
|
+
vi.save()
|
|
89
|
+
|
|
90
|
+
print(f"# Fetched metadata for {version_count} versions from {len(vi)} object(s) in bucket '{bucket}'")
|
|
91
|
+
|
|
92
|
+
return vi
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
except NoCredentialsError:
|
|
96
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
except ClientError as e:
|
|
99
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
def delete_from_s3(s3obj: str, version_id: str | None = None, profile_name: str | None = None):
|
|
103
|
+
"""Delete an object or specific version in S3.
|
|
104
|
+
|
|
105
|
+
If version_id is provided, deletes that specific version.
|
|
106
|
+
Otherwise, creates a delete marker in versioned buckets or deletes the object.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
bucket, key = normalize_bucket_name(s3obj)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
session = boto3.Session(profile_name=profile_name)
|
|
114
|
+
s3_client = session.client("s3")
|
|
115
|
+
|
|
116
|
+
if version_id:
|
|
117
|
+
print(f"# Deleting version {version_id} of s3://{bucket}/{key}...")
|
|
118
|
+
s3_client.delete_object(Bucket=bucket, Key=key, VersionId=version_id)
|
|
119
|
+
print(f"# Successfully deleted version {version_id} of s3://{bucket}/{key}")
|
|
120
|
+
else:
|
|
121
|
+
print(f"# Deleting s3://{bucket}/{key}...")
|
|
122
|
+
response = s3_client.delete_object(Bucket=bucket, Key=key)
|
|
123
|
+
|
|
124
|
+
if "DeleteMarker" in response and response["DeleteMarker"]:
|
|
125
|
+
print(f"# Successfully marked s3://{bucket}/{key} as deleted")
|
|
126
|
+
else:
|
|
127
|
+
print(f"# Successfully deleted s3://{bucket}/{key}")
|
|
128
|
+
|
|
129
|
+
except NoCredentialsError:
|
|
130
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
131
|
+
raise sys.exit(1)
|
|
132
|
+
except ClientError as e:
|
|
133
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
134
|
+
raise sys.exit(1)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
137
|
+
raise sys.exit(1)
|
|
138
|
+
|
|
139
|
+
def upload_to_s3(source: str, destination: str, profile_name: str | None = None):
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
"""Upload a file to S3."""
|
|
143
|
+
# Parse source file
|
|
144
|
+
source_path = Path(source)
|
|
145
|
+
if not source_path.exists():
|
|
146
|
+
print(f"Error: File not found: {source}")
|
|
147
|
+
raise sys.exit(1)
|
|
148
|
+
|
|
149
|
+
if not source_path.is_file():
|
|
150
|
+
print(f"Error: Not a file: {source}")
|
|
151
|
+
raise sys.exit(1)
|
|
152
|
+
|
|
153
|
+
# Parse destination
|
|
154
|
+
bucket, prefix = normalize_bucket_name(destination)
|
|
155
|
+
|
|
156
|
+
vi = VersionsIndex(bucket)
|
|
157
|
+
vi.load()
|
|
158
|
+
|
|
159
|
+
# If destination ends with /, append filename to prefix
|
|
160
|
+
if prefix.endswith("/"):
|
|
161
|
+
key = prefix + source_path.name
|
|
162
|
+
|
|
163
|
+
elif vi.has_directory(prefix):
|
|
164
|
+
# If prefix is a known directory in VersionsIndex, treat as directory
|
|
165
|
+
key = prefix.rstrip('/') + "/" + source_path.name
|
|
166
|
+
elif prefix:
|
|
167
|
+
# If prefix is not empty and doesn't end with /, treat it as a full path
|
|
168
|
+
key = prefix
|
|
169
|
+
else:
|
|
170
|
+
# If no prefix, just use filename
|
|
171
|
+
key = source_path.name
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
session = boto3.Session(profile_name=profile_name)
|
|
175
|
+
s3_client = session.client("s3")
|
|
176
|
+
|
|
177
|
+
file_size = source_path.stat().st_size
|
|
178
|
+
size_str = kmgt(file_size) if file_size > 0 else "0B"
|
|
179
|
+
|
|
180
|
+
print(f"# Uploading {source} to s3://{bucket}/{key} ({size_str})...", file=sys.stderr)
|
|
181
|
+
|
|
182
|
+
s3_client.upload_file(str(source_path), bucket, key)
|
|
183
|
+
|
|
184
|
+
print(f"# Successfully uploaded to s3://{bucket}/{key}", file=sys.stderr)
|
|
185
|
+
|
|
186
|
+
except NoCredentialsError:
|
|
187
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
188
|
+
raise sys.exit(1)
|
|
189
|
+
except ClientError as e:
|
|
190
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
191
|
+
raise sys.exit(1)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
194
|
+
raise sys.exit(1)
|
|
195
|
+
|
|
196
|
+
def download_from_s3(s3obj: str, destination: str, version_id: str | None = None, profile_name: str | None = None):
|
|
197
|
+
"""Download a file from S3."""
|
|
198
|
+
bucket, key = normalize_bucket_name(s3obj)
|
|
199
|
+
|
|
200
|
+
# Parse destination local path
|
|
201
|
+
dest_path = Path(destination)
|
|
202
|
+
|
|
203
|
+
# If destination is "." (current dir) or ends with "/", use source filename as basename
|
|
204
|
+
if destination == "." or destination.endswith("/"):
|
|
205
|
+
dest_path = dest_path / Path(key).name
|
|
206
|
+
|
|
207
|
+
# Create parent directory if needed
|
|
208
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
session = boto3.Session(profile_name=profile_name)
|
|
212
|
+
s3_client = session.client("s3")
|
|
213
|
+
|
|
214
|
+
print(f"# Downloading s3://{bucket}/{key} → {dest_path}...")
|
|
215
|
+
|
|
216
|
+
extra_args = {}
|
|
217
|
+
if version_id:
|
|
218
|
+
extra_args["VersionId"] = version_id
|
|
219
|
+
print(f" Using version: {version_id}")
|
|
220
|
+
|
|
221
|
+
s3_client.download_file(bucket, key, str(dest_path), ExtraArgs=extra_args if extra_args else None)
|
|
222
|
+
|
|
223
|
+
file_size = dest_path.stat().st_size
|
|
224
|
+
size_str = kmgt(file_size)
|
|
225
|
+
|
|
226
|
+
print(f"# Successfully downloaded ({size_str}) to {str(dest_path)}")
|
|
227
|
+
|
|
228
|
+
except NoCredentialsError:
|
|
229
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
230
|
+
raise sys.exit(1)
|
|
231
|
+
except ClientError as e:
|
|
232
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
233
|
+
raise sys.exit(1)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
236
|
+
raise sys.exit(1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def undelete_from_s3(s3obj: str, profile_name: str | None = None):
|
|
240
|
+
"""Undelete an object in S3 (remove the delete marker in versioned buckets)."""
|
|
241
|
+
bucket, key = normalize_bucket_name(s3obj)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
session = boto3.Session(profile_name=profile_name)
|
|
245
|
+
s3_client = session.client("s3")
|
|
246
|
+
|
|
247
|
+
print(f"# Undeleting s3://{bucket}/{key}...")
|
|
248
|
+
|
|
249
|
+
# List versions to find the latest delete marker for the key
|
|
250
|
+
paginator = s3_client.get_paginator("list_object_versions")
|
|
251
|
+
pages = paginator.paginate(Bucket=bucket, Prefix=key)
|
|
252
|
+
|
|
253
|
+
delete_marker_version = None
|
|
254
|
+
for page in pages:
|
|
255
|
+
for marker in page.get("DeleteMarkers", []):
|
|
256
|
+
if marker.get("Key") == key and marker.get("IsLatest"):
|
|
257
|
+
delete_marker_version = marker.get("VersionId")
|
|
258
|
+
break
|
|
259
|
+
if delete_marker_version:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
if not delete_marker_version:
|
|
263
|
+
print(f"Error: No delete marker found for s3://{bucket}/{key}", file=sys.stderr)
|
|
264
|
+
raise sys.exit(1)
|
|
265
|
+
|
|
266
|
+
# Delete the delete marker by specifying its version ID
|
|
267
|
+
s3_client.delete_object(Bucket=bucket, Key=key, VersionId=delete_marker_version)
|
|
268
|
+
|
|
269
|
+
print(f"# Successfully removed delete marker for s3://{bucket}/{key}", file=sys.stderr)
|
|
270
|
+
|
|
271
|
+
except NoCredentialsError:
|
|
272
|
+
print("Error: AWS credentials not found. Configure credentials in ~/.aws/ or set AWS_* environment variables.", file=sys.stderr)
|
|
273
|
+
raise sys.exit(1)
|
|
274
|
+
except ClientError as e:
|
|
275
|
+
print(f"AWS error: {e}", file=sys.stderr)
|
|
276
|
+
raise sys.exit(1)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
279
|
+
raise sys.exit(1)
|
|
280
|
+
|
|
281
|
+
def recover_object_version(
|
|
282
|
+
s3obj: str,
|
|
283
|
+
version_id: str,
|
|
284
|
+
profile_name: Optional[str] = None):
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
bucket, key = normalize_bucket_name(s3obj)
|
|
288
|
+
|
|
289
|
+
session = boto3.Session(profile_name=profile_name)
|
|
290
|
+
s3_client = session.client("s3")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
# Copy the specific version to itself, making it the current version
|
|
295
|
+
copy_source = {
|
|
296
|
+
'Bucket': bucket,
|
|
297
|
+
'Key': key,
|
|
298
|
+
'VersionId': version_id
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_ = s3_client.copy_object(
|
|
302
|
+
CopySource=copy_source,
|
|
303
|
+
Bucket=bucket,
|
|
304
|
+
Key=key
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
print(f'Successfully recovered version {version_id} as current version of s3://{bucket}/{key}')
|
|
308
|
+
|
|
309
|
+
except ClientError as e:
|
|
310
|
+
print(f'Failed to recover version {version_id}: {e}')
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from botocore.exceptions import NoCredentialsError, ClientError, ParamValidationError
|
|
5
|
+
|
|
6
|
+
from ..aws import list_buckets, delete_from_s3, upload_to_s3, download_from_s3, undelete_from_s3, recover_object_version
|
|
7
|
+
from ..ls import list_objects
|
|
8
|
+
|
|
9
|
+
def get_args():
|
|
10
|
+
parser = argparse.ArgumentParser(description="S3V CLI")
|
|
11
|
+
parser.add_argument("COMMAND", choices=["ls", "cp", "rm", "del", "delete", "recover", "rec", "r", "unrm"], help="The command to execute: ls, cp, rm, recover")
|
|
12
|
+
parser.add_argument("LOCATION1", nargs='?', help="The S3 or local location (e.g. s3://bucket/key or bucket/prefix or arhive.zip)")
|
|
13
|
+
parser.add_argument("LOCATION2", nargs='?', help="The S3 location for cp command (e.g., s3://bucket/key or bucket/prefix)")
|
|
14
|
+
|
|
15
|
+
parser.add_argument("--profile", help="AWS CLI profile to use for authentication")
|
|
16
|
+
parser.add_argument("-r", "--recursive", action="store_true", default=False, help="List all objects recursively (only for ls command)")
|
|
17
|
+
parser.add_argument("-s", "--version", metavar='S3_OBJECT_VERSION', help="Version specifier for rm and recover commands")
|
|
18
|
+
parser.add_argument("-e", "--etag", default=False, action="store_true", help="Print ETag (md5sum) for each version in ls command")
|
|
19
|
+
|
|
20
|
+
return parser.parse_args()
|
|
21
|
+
|
|
22
|
+
def guess_if_upload(loc1: str, loc2: str) -> bool | None:
|
|
23
|
+
"""Guess if the cp command is an upload or download based on the presence of s3:// and existence of local files."""
|
|
24
|
+
loc1_is_s3 = loc1.startswith("s3://")
|
|
25
|
+
loc2_is_s3 = loc2.startswith("s3://")
|
|
26
|
+
|
|
27
|
+
if loc1_is_s3 and not loc2_is_s3:
|
|
28
|
+
return False # Download
|
|
29
|
+
elif loc2_is_s3 and not loc1_is_s3:
|
|
30
|
+
return True # Upload
|
|
31
|
+
else:
|
|
32
|
+
# If both are local paths, guess based on file existence
|
|
33
|
+
if Path(loc1).exists() and not Path(loc2).exists():
|
|
34
|
+
return True # Upload
|
|
35
|
+
elif Path(loc2).exists() and not Path(loc1).exists():
|
|
36
|
+
return False # Download
|
|
37
|
+
else:
|
|
38
|
+
return None # Ambiguous, cannot guess
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
args= get_args()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
if args.COMMAND == "ls":
|
|
49
|
+
if args.LOCATION1 is None:
|
|
50
|
+
list_buckets(profile_name=args.profile)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
list_objects(bucket=args.LOCATION1, profile_name=args.profile, recursive=args.recursive, etag=args.etag)
|
|
54
|
+
return
|
|
55
|
+
elif args.COMMAND == "cp":
|
|
56
|
+
if args.LOCATION1 is None or args.LOCATION2 is None:
|
|
57
|
+
print("Error: cp command requires both source and destination locations.", file=sys.stderr)
|
|
58
|
+
return
|
|
59
|
+
if args.LOCATION1.startswith("s3://") and args.LOCATION2.startswith("s3://"):
|
|
60
|
+
print("Error: cp command does not support copying between two S3 locations. Please copy to/from local filesystem instead.", file=sys.stderr)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if guess_if_upload(args.LOCATION1, args.LOCATION2):
|
|
65
|
+
upload_to_s3(source=args.LOCATION1, destination=args.LOCATION2, profile_name=args.profile)
|
|
66
|
+
return
|
|
67
|
+
else:
|
|
68
|
+
download_from_s3(s3obj=args.LOCATION1, destination=args.LOCATION2, version_id=args.version, profile_name=args.profile)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
elif args.COMMAND in ["rm", "del", "delete"]:
|
|
72
|
+
delete_from_s3(s3obj=args.LOCATION1, version_id=args.version, profile_name=args.profile)
|
|
73
|
+
return
|
|
74
|
+
elif args.COMMAND in ["recover", "rec", "r", "undelete", "undel", "unrm"]:
|
|
75
|
+
if args.version:
|
|
76
|
+
# recover specific version
|
|
77
|
+
recover_object_version(s3obj=args.LOCATION1, version_id=args.version, profile_name=args.profile)
|
|
78
|
+
else:
|
|
79
|
+
# just recover the latest version if version is not specified
|
|
80
|
+
undelete_from_s3(s3obj=args.LOCATION1, profile_name=args.profile)
|
|
81
|
+
return
|
|
82
|
+
else:
|
|
83
|
+
print("Do not know how to handle command:", args.COMMAND, file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
except (NoCredentialsError, ClientError, ParamValidationError) as botocore_error:
|
|
87
|
+
print(f"Botocore Error: {botocore_error}", file=sys.stderr)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
s3v-0.1.0/s3v/ls.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from .aws import normalize_bucket_name, sync_versions
|
|
5
|
+
from .versions import VersionsIndex
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def list_objects(bucket: str, prefix: str = "", profile_name=None, recursive: bool = False, etag: bool = False):
|
|
9
|
+
"""List objects in a specific S3 bucket and prefix."""
|
|
10
|
+
|
|
11
|
+
bucket, prefix = normalize_bucket_name(bucket)
|
|
12
|
+
print(f"Listing objects in bucket '{bucket}' with prefix '{prefix}'")
|
|
13
|
+
vi = sync_versions(bucket, profile_name=profile_name)
|
|
14
|
+
|
|
15
|
+
# vi.dump()
|
|
16
|
+
|
|
17
|
+
# vi = VersionsIndex.load_from_file(Path.home() / ".cache" / "s3v" / bucket / "versions.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# exact match
|
|
21
|
+
if prefix in vi:
|
|
22
|
+
print(f"Objects under prefix '{prefix}':")
|
|
23
|
+
vo = vi[prefix]
|
|
24
|
+
#print("OLD:")
|
|
25
|
+
#print(json.dumps(vo.serialize(), indent=2))
|
|
26
|
+
#print()
|
|
27
|
+
#print("NEW:")
|
|
28
|
+
print(vo.ls_versions(strip_prefix=prefix, etag=etag))
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
# prefix list
|
|
32
|
+
|
|
33
|
+
if prefix and not prefix.endswith("/"):
|
|
34
|
+
prefix += "/"
|
|
35
|
+
|
|
36
|
+
for key in sorted(vi.keys()):
|
|
37
|
+
if key.startswith(prefix):
|
|
38
|
+
print(vi[key].ls_1line(strip_prefix=prefix))
|
s3v-0.1.0/s3v/misc.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from .misc import kmgt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def serialize_version(version_record: Dict) -> Dict:
|
|
9
|
+
# replace LastModified to string
|
|
10
|
+
# make copy of object to avoid mutating original
|
|
11
|
+
version_record = version_record.copy()
|
|
12
|
+
version_record["LastModified"] = version_record["LastModified"].isoformat()
|
|
13
|
+
return version_record
|
|
14
|
+
|
|
15
|
+
def unserialize_version(version_data: Dict) -> Dict:
|
|
16
|
+
version_data = version_data.copy()
|
|
17
|
+
version_data["LastModified"] = datetime.fromisoformat(version_data["LastModified"])
|
|
18
|
+
return version_data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VersionedObject:
|
|
22
|
+
def __init__(self, key: str):
|
|
23
|
+
self.key = key
|
|
24
|
+
self.versions = dict() # version_id -> metadata dict
|
|
25
|
+
self.delete_markers = dict()
|
|
26
|
+
|
|
27
|
+
def add_version(self, version_record: Dict):
|
|
28
|
+
version_id = version_record["VersionId"]
|
|
29
|
+
self.versions[version_id] = version_record
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_delete_marker(self, delete_marker_record: Dict):
|
|
33
|
+
version_id = delete_marker_record["VersionId"]
|
|
34
|
+
self.delete_markers[version_id] = delete_marker_record
|
|
35
|
+
|
|
36
|
+
def serialize(self) -> Dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"versions": [serialize_version(v) for v in self.versions.values()],
|
|
39
|
+
"delete_markers": [serialize_version(dm) for dm in self.delete_markers.values()]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"VersionedObject(key={self.key}, versions={len(self.versions)}, delete_markers={len(self.delete_markers)})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dump(self):
|
|
47
|
+
for version in self.versions.values():
|
|
48
|
+
print(f" Version: {version}")
|
|
49
|
+
for dm in self.delete_markers.values():
|
|
50
|
+
print(f" Delete Marker: {dm}")
|
|
51
|
+
|
|
52
|
+
def get_latest_version(self) -> Dict | None:
|
|
53
|
+
if not self.versions:
|
|
54
|
+
return None
|
|
55
|
+
return max(self.versions.values(), key=lambda v: v["LastModified"])
|
|
56
|
+
|
|
57
|
+
def ls_1line(self, strip_prefix: str | None = None) -> str:
|
|
58
|
+
latest_version = self.get_latest_version()
|
|
59
|
+
if latest_version is None:
|
|
60
|
+
return f"{self.key} (deleted, no versions)"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if strip_prefix and self.key.startswith(strip_prefix):
|
|
64
|
+
display_key = self.key[len(strip_prefix):]
|
|
65
|
+
else:
|
|
66
|
+
display_key = self.key
|
|
67
|
+
|
|
68
|
+
deleted = self.is_deleted()
|
|
69
|
+
status = " [DEL]" if deleted else " "
|
|
70
|
+
return f"{display_key:40}|{latest_version['LastModified'].strftime("%Y-%m-%d %H:%M:%S"):5}|{kmgt(latest_version['Size']):>15}| {len(self.versions):>3}|{status}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ls_versions(self, strip_prefix: str | None = None, etag: bool = False) -> str:
|
|
74
|
+
out = ''
|
|
75
|
+
if self.is_deleted():
|
|
76
|
+
out += f"{self.key} [deleted]\n"
|
|
77
|
+
else:
|
|
78
|
+
out += f"{self.key}\n"
|
|
79
|
+
|
|
80
|
+
for version in sorted(self.versions.values(), key=lambda v: v["LastModified"]):
|
|
81
|
+
if strip_prefix and self.key.startswith(strip_prefix):
|
|
82
|
+
display_key = self.key[len(strip_prefix):]
|
|
83
|
+
else:
|
|
84
|
+
display_key = self.key
|
|
85
|
+
out += f" {display_key} {version['VersionId']} {kmgt(version['Size']):>10} {version['LastModified'].strftime('%Y-%m-%d %H:%M:%S')}"
|
|
86
|
+
if etag:
|
|
87
|
+
out += f" {version['ETag']}"
|
|
88
|
+
out += "\n"
|
|
89
|
+
|
|
90
|
+
for dm in sorted(self.delete_markers.values(), key=lambda dm: dm["LastModified"]):
|
|
91
|
+
if strip_prefix and self.key.startswith(strip_prefix):
|
|
92
|
+
display_key = self.key[len(strip_prefix):]
|
|
93
|
+
else:
|
|
94
|
+
display_key = self.key
|
|
95
|
+
if dm['IsLatest']:
|
|
96
|
+
deleted_str = "[DELETED]"
|
|
97
|
+
else:
|
|
98
|
+
deleted_str = "[OLD DM]"
|
|
99
|
+
out += f" {display_key} {dm['VersionId']} {deleted_str:>10} {dm['LastModified'].strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
100
|
+
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def is_deleted(self) -> bool:
|
|
104
|
+
# if any IsLatest in delete markers is true, consider it deleted
|
|
105
|
+
return any(dm.get("IsLatest", False) for dm in self.delete_markers.values())
|
|
106
|
+
|
|
107
|
+
class VersionsIndex:
|
|
108
|
+
"""In-memory representation of the versions metadata JSON.
|
|
109
|
+
|
|
110
|
+
This class contains no AWS code and only operates on the JSON structure
|
|
111
|
+
produced by `_sync_versions` (mapping of key -> {"versions": [...], "delete_markers": [...]}).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
bucket_keys: Dict[str, VersionedObject]
|
|
115
|
+
|
|
116
|
+
def __init__(self, bucketname: str):
|
|
117
|
+
self.bucket_keys = dict()
|
|
118
|
+
self.bucketname = bucketname
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save(self):
|
|
122
|
+
cache_dir = Path.home() / ".cache" / "s3v"
|
|
123
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
cach_file = cache_dir / f"{self.bucketname}.json"
|
|
125
|
+
|
|
126
|
+
with open(cach_file, "w") as f:
|
|
127
|
+
json.dump({key: vo.serialize() for key, vo in self.bucket_keys.items()}, f, indent=2)
|
|
128
|
+
|
|
129
|
+
def load(self):
|
|
130
|
+
cache_dir = Path.home() / ".cache" / "s3v"
|
|
131
|
+
cach_file = cache_dir / f"{self.bucketname}.json"
|
|
132
|
+
|
|
133
|
+
if not cach_file.exists():
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
with open(cach_file, "r") as f:
|
|
137
|
+
raw_data = json.load(f)
|
|
138
|
+
for key, vo_data in raw_data.items():
|
|
139
|
+
vo = VersionedObject(key)
|
|
140
|
+
for version_record in vo_data.get("versions", []):
|
|
141
|
+
vo.add_version(unserialize_version(version_record))
|
|
142
|
+
for delete_marker_record in vo_data.get("delete_markers", []):
|
|
143
|
+
vo.add_delete_marker(unserialize_version(delete_marker_record))
|
|
144
|
+
self.bucket_keys[key] = vo
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def keys(self):
|
|
148
|
+
return list(self.bucket_keys.keys())
|
|
149
|
+
|
|
150
|
+
def get(self, key: str):
|
|
151
|
+
return self.bucket_keys.get(key)
|
|
152
|
+
|
|
153
|
+
def __setitem__(self, key: str, value: VersionedObject):
|
|
154
|
+
self.bucket_keys[key] = value
|
|
155
|
+
|
|
156
|
+
def __getitem__(self, key: str) -> VersionedObject:
|
|
157
|
+
return self.bucket_keys[key]
|
|
158
|
+
|
|
159
|
+
def __contains__(self, key: str):
|
|
160
|
+
return key in self.bucket_keys
|
|
161
|
+
|
|
162
|
+
# len()
|
|
163
|
+
def __len__(self):
|
|
164
|
+
return len(self.bucket_keys)
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return f"VersionsIndex(bucket={self.bucketname}, keys={len(self.bucket_keys)})"
|
|
168
|
+
|
|
169
|
+
def has_directory(self, dirname: str) -> bool:
|
|
170
|
+
"""Check if any key starts with the given directory name."""
|
|
171
|
+
prefix = dirname
|
|
172
|
+
if not prefix.endswith("/"):
|
|
173
|
+
prefix += "/"
|
|
174
|
+
for key in self.bucket_keys.keys():
|
|
175
|
+
if key.startswith(prefix):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def dump(self):
|
|
180
|
+
for key, vo in self.bucket_keys.items():
|
|
181
|
+
print(f"{key}")
|
|
182
|
+
vo.dump()
|
|
183
|
+
print()
|