salt-bundle 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """salt-bundle - Salt package manager."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from . import (
6
+ config,
7
+ lockfile,
8
+ package,
9
+ repository,
10
+ resolver,
11
+ vendor,
12
+ )
13
+
14
+ __all__ = [
15
+ "config",
16
+ "lockfile",
17
+ "package",
18
+ "repository",
19
+ "resolver",
20
+ "vendor",
21
+ ]
salt_bundle/cli.py ADDED
@@ -0,0 +1,419 @@
1
+ """CLI interface for salt-bundle."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ # Handle both package import and direct execution
9
+ try:
10
+ from . import config, lockfile, package, release, repository, resolver, vendor
11
+ from .models.config_models import ProjectConfig, RepositoryConfig
12
+ from .models.package_models import PackageMeta
13
+ except ImportError:
14
+ # Direct execution - add parent directory to path
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+ from salt_bundle import config, lockfile, package, release, repository, resolver, vendor
17
+ from salt_bundle.models.config_models import ProjectConfig, RepositoryConfig
18
+ from salt_bundle.models.package_models import PackageMeta
19
+
20
+
21
+ @click.group()
22
+ @click.option('--debug', is_flag=True, help='Enable debug mode')
23
+ @click.option('--quiet', is_flag=True, help='Suppress output')
24
+ @click.option('--project-dir', '-C', type=click.Path(exists=True, file_okay=False, dir_okay=True),
25
+ help='Project directory (default: current directory)')
26
+ @click.pass_context
27
+ def cli(ctx, debug, quiet, project_dir):
28
+ """Salt package manager."""
29
+ ctx.ensure_object(dict)
30
+ ctx.obj['DEBUG'] = debug
31
+ ctx.obj['QUIET'] = quiet
32
+ ctx.obj['PROJECT_DIR'] = Path(project_dir) if project_dir else Path.cwd()
33
+
34
+
35
+ @cli.command()
36
+ @click.option('--project', 'config_type', flag_value='project', help='Initialize project configuration')
37
+ @click.option('--formula', 'config_type', flag_value='formula', help='Initialize formula configuration')
38
+ @click.option('--force', is_flag=True, help='Overwrite existing configuration')
39
+ @click.pass_context
40
+ def init(ctx, config_type, force):
41
+ """Initialize salt-bundle configuration."""
42
+ if not config_type:
43
+ click.echo("Error: Specify --project or --formula", err=True)
44
+ sys.exit(1)
45
+
46
+ project_dir = ctx.obj['PROJECT_DIR']
47
+ config_file = project_dir / '.saltbundle.yaml'
48
+
49
+ if config_file.exists() and not force:
50
+ click.echo(f"Error: {config_file} already exists. Use --force to overwrite.", err=True)
51
+ sys.exit(1)
52
+
53
+ if config_type == 'project':
54
+ name = click.prompt("Project name", default="my-project")
55
+ version = click.prompt("Version", default="0.1.0")
56
+
57
+ project_config = ProjectConfig(
58
+ project=name,
59
+ version=version,
60
+ vendor_dir="vendor",
61
+ repositories=[],
62
+ dependencies={}
63
+ )
64
+
65
+ config.save_project_config(project_config, project_dir)
66
+ click.echo(f"Created project configuration: {config_file}")
67
+
68
+ elif config_type == 'formula':
69
+ name = click.prompt("Formula name")
70
+ version = click.prompt("Version", default="1.0.0")
71
+ description = click.prompt("Description", default="")
72
+
73
+ # Salt compatibility
74
+ salt_min = click.prompt("Salt min version", default="", show_default=False)
75
+ salt_max = click.prompt("Salt max version", default="", show_default=False)
76
+
77
+ # Import SaltCompatibility model
78
+ from salt_bundle.models.package_models import SaltCompatibility
79
+
80
+ salt_compat = None
81
+ if salt_min or salt_max:
82
+ salt_compat = SaltCompatibility(
83
+ min_version=salt_min if salt_min else None,
84
+ max_version=salt_max if salt_max else None
85
+ )
86
+
87
+ formula_meta = PackageMeta(
88
+ name=name,
89
+ version=version,
90
+ description=description if description else None,
91
+ salt=salt_compat
92
+ )
93
+
94
+ config.save_package_meta(formula_meta, project_dir)
95
+ click.echo(f"Created formula configuration: {config_file}")
96
+
97
+
98
+ @cli.command()
99
+ @click.option('--output-dir', '-o', type=click.Path(), help='Output directory')
100
+ @click.pass_context
101
+ def pack(ctx, output_dir):
102
+ """Pack formula into tar.gz archive."""
103
+ try:
104
+ project_dir = ctx.obj['PROJECT_DIR']
105
+ output_path = Path(output_dir) if output_dir else project_dir
106
+ archive_path = package.pack_formula(project_dir, output_path)
107
+ click.echo(f"Created package: {archive_path}")
108
+ except Exception as e:
109
+ click.echo(f"Error: {e}", err=True)
110
+ sys.exit(1)
111
+
112
+
113
+ @cli.command()
114
+ @click.argument('directory', type=click.Path(exists=True), default='.')
115
+ @click.option('--output-dir', '-o', type=click.Path(), help='Output directory for index.yaml (default: same as input directory)')
116
+ @click.option('--base-url', '-u', help='Base URL for package links in index (e.g., https://example.com/repo/)')
117
+ @click.pass_context
118
+ def index(ctx, directory, output_dir, base_url):
119
+ """Generate or update repository index."""
120
+ try:
121
+ repo_dir = Path(directory)
122
+ output_path = Path(output_dir) if output_dir else repo_dir
123
+
124
+ # Ensure output directory exists
125
+ output_path.mkdir(parents=True, exist_ok=True)
126
+
127
+ idx = repository.generate_index(repo_dir, base_url=base_url)
128
+ repository.save_index(idx, output_path)
129
+
130
+ click.echo(f"Generated index with {len(idx.packages)} packages")
131
+ for name, entries in idx.packages.items():
132
+ click.echo(f" {name}: {len(entries)} versions")
133
+
134
+ if output_path != repo_dir:
135
+ click.echo(f"Index saved to: {output_path / 'index.yaml'}")
136
+ except Exception as e:
137
+ click.echo(f"Error: {e}", err=True)
138
+ sys.exit(1)
139
+
140
+
141
+ @cli.command('add-repo')
142
+ @click.option('--name', required=True, help='Repository name')
143
+ @click.option('--url', required=True, help='Repository URL')
144
+ @click.pass_context
145
+ def add_repo(ctx, name, url):
146
+ """Add repository to user configuration."""
147
+ try:
148
+ config.add_user_repository(name, url)
149
+ click.echo(f"Added repository: {name} -> {url}")
150
+ except ValueError as e:
151
+ click.echo(f"Error: {e}", err=True)
152
+ sys.exit(1)
153
+
154
+
155
+ @cli.command()
156
+ @click.option('--no-lock', is_flag=True, help='Ignore lock file')
157
+ @click.option('--update-lock', is_flag=True, help='Update lock file')
158
+ @click.pass_context
159
+ def install(ctx, no_lock, update_lock):
160
+ """Install project dependencies."""
161
+ try:
162
+ project_dir = ctx.obj['PROJECT_DIR']
163
+
164
+ # Load project config
165
+ try:
166
+ proj_config = config.load_project_config(project_dir)
167
+ except FileNotFoundError:
168
+ click.echo("Error: .saltbundle.yaml not found. Run 'salt-bundle init --project' first.", err=True)
169
+ sys.exit(1)
170
+
171
+ vendor_dir = vendor.get_vendor_dir(project_dir, proj_config.vendor_dir)
172
+ vendor.ensure_vendor_dir(vendor_dir)
173
+
174
+ # Get all repositories (project + user)
175
+ user_config = config.load_user_config()
176
+ all_repos = proj_config.repositories + user_config.repositories
177
+
178
+ if not all_repos:
179
+ click.echo("Warning: No repositories configured", err=True)
180
+
181
+ # Check if we need to resolve dependencies
182
+ lock_file_exists = lockfile.lockfile_exists(project_dir)
183
+
184
+ if lock_file_exists and not no_lock and not update_lock:
185
+ # Install from lock file
186
+ click.echo("Installing from lock file...")
187
+ lock = lockfile.load_lockfile(project_dir)
188
+ else:
189
+ # Resolve dependencies
190
+ click.echo("Resolving dependencies...")
191
+ lock = lockfile.LockFile()
192
+
193
+ for dep_name, dep_constraint in proj_config.dependencies.items():
194
+ resolved = None
195
+
196
+ # Try each repository
197
+ for repo in all_repos:
198
+ try:
199
+ idx = repository.fetch_index(repo.url)
200
+ if dep_name in idx.packages:
201
+ resolved_entry = resolver.resolve_version(dep_constraint, idx.packages[dep_name])
202
+ if resolved_entry:
203
+ lockfile.add_locked_dependency(
204
+ lock,
205
+ dep_name,
206
+ resolved_entry.version,
207
+ repo.name,
208
+ resolved_entry.url,
209
+ resolved_entry.digest
210
+ )
211
+ resolved = resolved_entry
212
+ break
213
+ except Exception as e:
214
+ click.echo(f"Warning: Failed to fetch from {repo.name}: {e}", err=True)
215
+
216
+ if not resolved:
217
+ click.echo(f"Error: Could not resolve dependency: {dep_name} {dep_constraint}", err=True)
218
+ sys.exit(1)
219
+
220
+ # Save lock file
221
+ lockfile.save_lockfile(lock, project_dir)
222
+
223
+ # Install packages
224
+ for dep_name, locked_dep in lock.dependencies.items():
225
+ click.echo(f"Installing {dep_name} {locked_dep.version}...")
226
+
227
+ # Find repository URL
228
+ repo_url = None
229
+ for repo in all_repos:
230
+ if repo.name == locked_dep.repository:
231
+ repo_url = repo.url
232
+ break
233
+
234
+ if not repo_url:
235
+ click.echo(f"Error: Repository not found: {locked_dep.repository}", err=True)
236
+ sys.exit(1)
237
+
238
+ # Download package
239
+ archive_path = repository.download_package(
240
+ locked_dep.url,
241
+ repo_url,
242
+ locked_dep.digest
243
+ )
244
+
245
+ # Install to vendor
246
+ vendor.install_package_to_vendor(archive_path, dep_name, vendor_dir)
247
+
248
+ click.echo("Installation complete!")
249
+
250
+ except Exception as e:
251
+ click.echo(f"Error: {e}", err=True)
252
+ if ctx.obj.get('DEBUG'):
253
+ import traceback
254
+ traceback.print_exc()
255
+ sys.exit(1)
256
+
257
+
258
+ @cli.command()
259
+ @click.pass_context
260
+ def vendor_cmd(ctx):
261
+ """Install dependencies from lock file (reproducible deploy)."""
262
+ try:
263
+ # Reuse install logic with --no-lock flag behavior
264
+ ctx.invoke(install, no_lock=False, update_lock=False)
265
+ except Exception as e:
266
+ click.echo(f"Error: {e}", err=True)
267
+ sys.exit(1)
268
+
269
+
270
+ @cli.command()
271
+ @click.pass_context
272
+ def verify(ctx):
273
+ """Verify project dependencies integrity."""
274
+ try:
275
+ project_dir = ctx.obj['PROJECT_DIR']
276
+
277
+ # Load lock file
278
+ try:
279
+ lock = lockfile.load_lockfile(project_dir)
280
+ except FileNotFoundError:
281
+ click.echo("Error: salt-bundle.lock not found", err=True)
282
+ sys.exit(1)
283
+
284
+ # Load project config
285
+ proj_config = config.load_project_config(project_dir)
286
+ vendor_dir = vendor.get_vendor_dir(project_dir, proj_config.vendor_dir)
287
+
288
+ errors = []
289
+
290
+ for dep_name, locked_dep in lock.dependencies.items():
291
+ # Check if installed
292
+ if not vendor.is_package_installed(dep_name, vendor_dir):
293
+ errors.append(f" {dep_name}: not installed")
294
+ continue
295
+
296
+ # Check .saltbundle.yaml exists
297
+ package_meta_file = vendor_dir / dep_name / '.saltbundle.yaml'
298
+ if not package_meta_file.exists():
299
+ errors.append(f" {dep_name}: .saltbundle.yaml missing")
300
+ continue
301
+
302
+ click.echo(f"✓ {dep_name} {locked_dep.version}")
303
+
304
+ if errors:
305
+ click.echo("\nErrors found:")
306
+ for error in errors:
307
+ click.echo(error, err=True)
308
+ sys.exit(1)
309
+ else:
310
+ click.echo("\nAll dependencies verified successfully!")
311
+
312
+ except Exception as e:
313
+ click.echo(f"Error: {e}", err=True)
314
+ sys.exit(1)
315
+
316
+
317
+ @cli.command()
318
+ @click.option('--formulas-dir', '-f', type=click.Path(exists=True, file_okay=False, dir_okay=True),
319
+ default='.', help='Directory containing formulas (default: current directory)')
320
+ @click.option('--repo-dir', '-r', type=click.Path(file_okay=False, dir_okay=True), required=True,
321
+ help='Repository directory where packages will be published')
322
+ @click.option('--skip-packaging', is_flag=True, help='Skip packaging step (use existing .tgz files)')
323
+ @click.option('--create-tags', is_flag=True, help='Create local git tags for releases')
324
+ @click.option('--github-release', is_flag=True, help='Create GitHub releases and upload packages (requires GITHUB_TOKEN and GITHUB_REPOSITORY env vars)')
325
+ @click.option('--index-branch', type=str, help='Git branch to commit index.yaml (e.g., gh-pages)')
326
+ @click.option('--dry-run', is_flag=True, help='Show what would be done without doing it')
327
+ @click.option('--single', is_flag=True, help='Treat formulas-dir as a single formula directory (not subdirectories)')
328
+ @click.pass_context
329
+ def release_cmd(ctx, formulas_dir, repo_dir, skip_packaging, create_tags, github_release, index_branch, dry_run, single):
330
+ """Release formulas to repository.
331
+
332
+ This command automates the process of:
333
+ 1. Discovering formulas in the specified directory
334
+ 2. Detecting new versions (not in repository)
335
+ 3. Packaging formulas (unless --skip-packaging)
336
+ 4. Publishing to repository
337
+ 5. Updating repository index
338
+ 6. Optionally creating git tags
339
+ 7. Optionally creating GitHub releases with package uploads
340
+ 8. Optionally committing index.yaml to a separate branch
341
+
342
+ By default, searches for formulas in subdirectories. Use --single to treat
343
+ the directory itself as a single formula.
344
+
345
+ GitHub Integration:
346
+ When --github-release is used, requires:
347
+ - GITHUB_TOKEN: Personal access token with repo permissions
348
+ - GITHUB_REPOSITORY: Repository in format 'owner/repo'
349
+
350
+ Creates GitHub releases with tag format: {package-name}-{version}
351
+ Uploads .tgz packages as release assets.
352
+
353
+ Index Branch (like Helm Chart Releaser):
354
+ Use --index-branch to commit index.yaml to a separate orphan branch:
355
+ --index-branch gh-pages
356
+ This allows serving the index via GitHub Pages or keeping it separate from source.
357
+ """
358
+ try:
359
+ formulas_path = Path(formulas_dir)
360
+ repo_path = Path(repo_dir)
361
+
362
+ if dry_run:
363
+ click.echo("=== DRY RUN MODE ===")
364
+
365
+ mode = "single formula" if single else "multiple formulas"
366
+ click.echo(f"Mode: {mode}")
367
+ click.echo(f"Formulas directory: {formulas_path}")
368
+ click.echo(f"Repository directory: {repo_path}")
369
+ click.echo()
370
+
371
+ # Run release process
372
+ released, errors = release.release_formulas(
373
+ formulas_path,
374
+ repo_path,
375
+ skip_packaging=skip_packaging,
376
+ create_tags=create_tags,
377
+ dry_run=dry_run,
378
+ single_formula=single,
379
+ github_release=github_release,
380
+ index_branch=index_branch
381
+ )
382
+
383
+ # Summary
384
+ click.echo()
385
+ click.echo("=" * 50)
386
+ click.echo("RELEASE SUMMARY")
387
+ click.echo("=" * 50)
388
+
389
+ if released:
390
+ click.echo(f"\n✓ Released {len(released)} package(s):")
391
+ for formula in released:
392
+ click.echo(f" - {formula.name} {formula.version}")
393
+ else:
394
+ click.echo("\nNo packages released")
395
+
396
+ if errors:
397
+ click.echo(f"\n✗ Errors ({len(errors)}):")
398
+ for error in errors:
399
+ click.echo(f" - {error}", err=True)
400
+ sys.exit(1)
401
+
402
+ if dry_run:
403
+ click.echo("\n[DRY RUN] No changes were made")
404
+
405
+ except Exception as e:
406
+ click.echo(f"Error: {e}", err=True)
407
+ if ctx.obj.get('DEBUG'):
408
+ import traceback
409
+ traceback.print_exc()
410
+ sys.exit(1)
411
+
412
+
413
+ def main():
414
+ """Entry point for CLI."""
415
+ cli(obj={})
416
+
417
+
418
+ if __name__ == '__main__':
419
+ main()
salt_bundle/config.py ADDED
@@ -0,0 +1,143 @@
1
+ """Configuration management for salt-bundle."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .models.config_models import ProjectConfig, UserConfig, RepositoryConfig
8
+ from .models.package_models import PackageMeta
9
+ from .utils.yaml import load_yaml, dump_yaml
10
+
11
+
12
+ def get_config_dir() -> Path:
13
+ """Get user configuration directory (XDG compliant).
14
+
15
+ Returns:
16
+ Path to config directory (~/.config/salt-bundle)
17
+ """
18
+ xdg_config = os.environ.get('XDG_CONFIG_HOME')
19
+ if xdg_config:
20
+ config_dir = Path(xdg_config) / 'salt-bundle'
21
+ else:
22
+ config_dir = Path.home() / '.config' / 'salt-bundle'
23
+
24
+ config_dir.mkdir(parents=True, exist_ok=True)
25
+ return config_dir
26
+
27
+
28
+ def get_cache_dir() -> Path:
29
+ """Get user cache directory (XDG compliant).
30
+
31
+ Returns:
32
+ Path to cache directory (~/.cache/salt-bundle)
33
+ """
34
+ xdg_cache = os.environ.get('XDG_CACHE_HOME')
35
+ if xdg_cache:
36
+ cache_dir = Path(xdg_cache) / 'salt-bundle'
37
+ else:
38
+ cache_dir = Path.home() / '.cache' / 'salt-bundle'
39
+
40
+ cache_dir.mkdir(parents=True, exist_ok=True)
41
+ return cache_dir
42
+
43
+
44
+ def load_user_config() -> UserConfig:
45
+ """Load user global configuration.
46
+
47
+ Returns:
48
+ UserConfig object (returns empty config if file doesn't exist)
49
+ """
50
+ config_file = get_config_dir() / 'config.yaml'
51
+
52
+ if not config_file.exists():
53
+ return UserConfig()
54
+
55
+ data = load_yaml(config_file)
56
+ return UserConfig(**data)
57
+
58
+
59
+ def save_user_config(config: UserConfig) -> None:
60
+ """Save user global configuration.
61
+
62
+ Args:
63
+ config: UserConfig object to save
64
+ """
65
+ config_file = get_config_dir() / 'config.yaml'
66
+ dump_yaml(config.model_dump(), config_file)
67
+
68
+
69
+ def add_user_repository(name: str, url: str) -> None:
70
+ """Add repository to user configuration.
71
+
72
+ Args:
73
+ name: Repository name
74
+ url: Repository URL
75
+
76
+ Raises:
77
+ ValueError: If repository with same name already exists
78
+ """
79
+ config = load_user_config()
80
+
81
+ # Check if repository with same name exists
82
+ for repo in config.repositories:
83
+ if repo.name == name:
84
+ raise ValueError(f"Repository '{name}' already exists")
85
+
86
+ config.repositories.append(RepositoryConfig(name=name, url=url))
87
+ save_user_config(config)
88
+
89
+
90
+ def load_project_config(project_dir: Path | str = Path.cwd()) -> ProjectConfig:
91
+ """Load project configuration from .saltbundle.yaml.
92
+
93
+ Args:
94
+ project_dir: Project directory (defaults to current directory)
95
+
96
+ Returns:
97
+ ProjectConfig object
98
+
99
+ Raises:
100
+ FileNotFoundError: If .saltbundle.yaml doesn't exist
101
+ """
102
+ config_file = Path(project_dir) / '.saltbundle.yaml'
103
+ data = load_yaml(config_file)
104
+ return ProjectConfig(**data)
105
+
106
+
107
+ def save_project_config(config: ProjectConfig, project_dir: Path | str = Path.cwd()) -> None:
108
+ """Save project configuration to .saltbundle.yaml.
109
+
110
+ Args:
111
+ config: ProjectConfig object to save
112
+ project_dir: Project directory (defaults to current directory)
113
+ """
114
+ config_file = Path(project_dir) / '.saltbundle.yaml'
115
+ dump_yaml(config.model_dump(exclude_none=True), config_file)
116
+
117
+
118
+ def load_package_meta(package_dir: Path | str = Path.cwd()) -> PackageMeta:
119
+ """Load package metadata from .saltbundle.yaml.
120
+
121
+ Args:
122
+ package_dir: Package directory (defaults to current directory)
123
+
124
+ Returns:
125
+ PackageMeta object
126
+
127
+ Raises:
128
+ FileNotFoundError: If .saltbundle.yaml doesn't exist
129
+ """
130
+ meta_file = Path(package_dir) / '.saltbundle.yaml'
131
+ data = load_yaml(meta_file)
132
+ return PackageMeta(**data)
133
+
134
+
135
+ def save_package_meta(meta: PackageMeta, package_dir: Path | str = Path.cwd()) -> None:
136
+ """Save package metadata to .saltbundle.yaml.
137
+
138
+ Args:
139
+ meta: PackageMeta object to save
140
+ package_dir: Package directory (defaults to current directory)
141
+ """
142
+ meta_file = Path(package_dir) / '.saltbundle.yaml'
143
+ dump_yaml(meta.model_dump(exclude_none=True), meta_file)