net-install-manager 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. net_install_manager/__init__.py +17 -0
  2. net_install_manager/app_manager.py +339 -0
  3. net_install_manager/cli_output.py +23 -0
  4. net_install_manager/commands/__init__.py +0 -0
  5. net_install_manager/commands/app_details.py +59 -0
  6. net_install_manager/commands/install.py +114 -0
  7. net_install_manager/commands/install_release.py +142 -0
  8. net_install_manager/commands/list_apps.py +37 -0
  9. net_install_manager/commands/list_versions.py +68 -0
  10. net_install_manager/commands/rollback.py +59 -0
  11. net_install_manager/commands/uninstall.py +71 -0
  12. net_install_manager/commands/upgrade.py +272 -0
  13. net_install_manager/config.py +124 -0
  14. net_install_manager/errors.py +35 -0
  15. net_install_manager/net_install_manager.py +268 -0
  16. net_install_manager/paths.py +73 -0
  17. net_install_manager/registry.py +176 -0
  18. net_install_manager/runtime_config.py +59 -0
  19. net_install_manager/utilities/__init__.py +0 -0
  20. net_install_manager/utilities/csproj_tools.py +112 -0
  21. net_install_manager/utilities/git_tools.py +219 -0
  22. net_install_manager/utilities/github_release_tools.py +202 -0
  23. net_install_manager/utilities/install_tools.py +189 -0
  24. net_install_manager/utilities/os_ops.py +325 -0
  25. net_install_manager/utilities/resolve_manager.py +33 -0
  26. net_install_manager/utilities/sh.py +84 -0
  27. net_install_manager/utilities/sys_platform.py +133 -0
  28. net_install_manager/utilities/version_tools.py +117 -0
  29. net_install_manager-0.1.0.dist-info/METADATA +105 -0
  30. net_install_manager-0.1.0.dist-info/RECORD +33 -0
  31. net_install_manager-0.1.0.dist-info/WHEEL +4 -0
  32. net_install_manager-0.1.0.dist-info/entry_points.txt +3 -0
  33. net_install_manager-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,17 @@
1
+ """Net Install Manager (`ninman`) package."""
2
+
3
+ from net_install_manager.config import Config, load_config, parse_config, auto_discover_config
4
+ from net_install_manager.paths import Paths, get_paths
5
+ from net_install_manager.app_manager import AppManager, AppManagerError, VersionRegressionError
6
+
7
+ __all__ = [
8
+ "Config",
9
+ "load_config",
10
+ "parse_config",
11
+ "auto_discover_config",
12
+ "Paths",
13
+ "get_paths",
14
+ "AppManager",
15
+ "AppManagerError",
16
+ "VersionRegressionError",
17
+ ]
@@ -0,0 +1,339 @@
1
+ """Unified application manager for installing and managing .NET applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import shutil
7
+ import logging
8
+ import semver
9
+
10
+ from net_install_manager.config import Config
11
+ from net_install_manager.paths import get_paths
12
+ from net_install_manager.registry import AppRegistry, TrackedApp
13
+ from net_install_manager.runtime_config import InstallOptions, RuntimeInfo
14
+ from net_install_manager.utilities.sys_platform import PlatformInfo, OperatingSystem
15
+ from net_install_manager.utilities.os_ops import (
16
+ OsOpsProtocol,
17
+ get_os_ops,
18
+ )
19
+ from net_install_manager.utilities.csproj_tools import (
20
+ publish as dotnet_publish,
21
+ )
22
+ from net_install_manager.utilities.version_tools import (
23
+ parse_semver,
24
+ resolve_version,
25
+ )
26
+ from net_install_manager.utilities.install_tools import (
27
+ InstallSource,
28
+ get_buildable_source,
29
+ get_paths_no_buildable_source,
30
+ get_paths_with_buildable_source,
31
+ )
32
+ from net_install_manager.errors import AppManagerError, VersionRegressionError
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class AppManager:
38
+ """Manages the installation, rollback, uninstallation, and listing of a .NET application."""
39
+
40
+ def __init__(
41
+ self,
42
+ config: Config,
43
+ prefer_opt: bool = False,
44
+ runtime: RuntimeInfo | None = None,
45
+ os_ops: type[OsOpsProtocol] | None = None,
46
+ registry: AppRegistry | None = None,
47
+ ):
48
+ self.config = config
49
+ self.runtime = runtime or RuntimeInfo.current()
50
+ self.os_ops = os_ops or get_os_ops(self.runtime.os)
51
+ self.registry = registry or AppRegistry(runtime=self.runtime)
52
+ self.paths = get_paths(
53
+ config=self.config,
54
+ prefer_opt=prefer_opt,
55
+ runtime=self.runtime,
56
+ )
57
+
58
+ def with_config(self, config: Config) -> AppManager:
59
+ """Return a new AppManager instance with the given configuration."""
60
+ return AppManager(
61
+ config=config,
62
+ prefer_opt=self.prefer_opt,
63
+ runtime=self.runtime,
64
+ os_ops=self.os_ops,
65
+ registry=self.registry,
66
+ )
67
+
68
+ @property
69
+ def prefer_opt(self) -> bool:
70
+ """Return whether target paths use /opt."""
71
+ return self.paths.lib_dir.as_posix().startswith("/opt/")
72
+
73
+ @property
74
+ def platform(self) -> PlatformInfo:
75
+ """Return platform info for this application manager."""
76
+ return self.runtime.platform
77
+
78
+ @property
79
+ def target_os(self) -> OperatingSystem:
80
+ """Return target operating system for backward compatibility."""
81
+ return self.runtime.os
82
+
83
+ def get_installed_versions(self) -> list[semver.Version]:
84
+ """Return a sorted list of installed versions (highest first)."""
85
+ if not self.paths.versions_dir.exists() or not self.paths.versions_dir.is_dir():
86
+ return []
87
+
88
+ versions: list[semver.Version] = []
89
+ for item in self.paths.versions_dir.iterdir():
90
+ if item.is_dir() and item.name != "current":
91
+ v = parse_semver(item.name)
92
+ if v is not None:
93
+ versions.append(v)
94
+
95
+ versions.sort(reverse=True)
96
+ return versions
97
+
98
+ def get_current_version(self) -> semver.Version | None:
99
+ """Get the version currently targeted by the 'current' link/junction."""
100
+ if not self.paths.current_dir.exists(follow_symlinks=False):
101
+ return None
102
+
103
+ try:
104
+ target = self.paths.current_dir.resolve()
105
+ return parse_semver(target.name)
106
+ except Exception: # pylint: disable=broad-except
107
+ return None
108
+
109
+ def install(
110
+ self,
111
+ source: InstallSource,
112
+ options: InstallOptions,
113
+ registry_source: InstallSource | None = None,
114
+ ) -> semver.Version:
115
+ """Install application from source (.csproj, git repo, or precompiled directory)."""
116
+ buildable_source = get_buildable_source(source=source, quiet=options.quiet)
117
+
118
+ temp_staging_dir: Path | None = None
119
+ copy_source: Path
120
+ binary_path: Path
121
+
122
+ try:
123
+ if buildable_source:
124
+ copy_source, binary_path, temp_staging_dir = get_paths_with_buildable_source(
125
+ binary_name=self.config.source_binary_name
126
+ )
127
+ logger.info(
128
+ "Publishing %s to %s...", buildable_source.csproj_path, temp_staging_dir
129
+ )
130
+ dotnet_publish(
131
+ csproj_path=buildable_source.csproj_path,
132
+ output_dir=temp_staging_dir,
133
+ opts=options.to_build_opts(platform=self.platform),
134
+ quiet=options.quiet,
135
+ )
136
+ else:
137
+ copy_source, binary_path = get_paths_no_buildable_source(
138
+ binary_name=self.config.source_binary_name,
139
+ source=source,
140
+ quiet=options.quiet,
141
+ )
142
+
143
+ version = resolve_version(
144
+ explicit_version=options.explicit_version,
145
+ csproj_path=buildable_source.csproj_path if buildable_source else None,
146
+ binary_path=binary_path,
147
+ source_dir=buildable_source.source_root if buildable_source else copy_source,
148
+ )
149
+
150
+ # Version regression check
151
+ installed_versions = self.get_installed_versions()
152
+ if not options.force and installed_versions and installed_versions[0] >= version:
153
+ raise VersionRegressionError(
154
+ f"Version {installed_versions[0]} is already installed, which is newer than "
155
+ f"or equal to the version being installed ({version}). Use --force to override."
156
+ )
157
+
158
+ target_version_dir = self.paths.versions_dir / str(version)
159
+
160
+ # Transfer files
161
+ logger.info("Transferring files to %s...", target_version_dir)
162
+ self.os_ops.mirror_directory(
163
+ src=copy_source,
164
+ dest=target_version_dir,
165
+ exclude_patterns=options.exclude_patterns,
166
+ )
167
+
168
+ # Executable & Permissions
169
+ executable_path = target_version_dir / self.config.source_binary_name
170
+ self.os_ops.make_executable(executable_path)
171
+ if self.config.target_permissions:
172
+ self.os_ops.set_permissions(
173
+ target_version_dir,
174
+ permissions=self.config.target_permissions,
175
+ )
176
+
177
+ # Update 'current' link
178
+ logger.info("Updating 'current' link...")
179
+ self.os_ops.update_current_link(
180
+ versions_dir=self.paths.versions_dir,
181
+ target_version=str(version),
182
+ current_dir=self.paths.current_dir,
183
+ )
184
+
185
+ # Update launcher in bin
186
+ target_current_exec = self.paths.current_dir / self.config.source_binary_name
187
+ logger.info("Updating binary launcher in %s...", self.paths.bin_dir)
188
+ self.os_ops.create_bin_launcher(
189
+ bin_dir=self.paths.bin_dir,
190
+ bin_name=self.config.binary_name,
191
+ target_executable=target_current_exec,
192
+ )
193
+
194
+ # Prune old versions if keep_latest specified
195
+ if options.keep_latest and options.keep_latest > 0:
196
+ self.prune_versions(options.keep_latest)
197
+
198
+ # Track in registry
199
+ try:
200
+ tracked = TrackedApp(
201
+ binary_name=self.config.binary_name,
202
+ app_directory=self.config.app_directory,
203
+ source_binary_name=self.config.source_binary_name,
204
+ source=registry_source
205
+ or (buildable_source.source if buildable_source else source),
206
+ lib_path=str(self.paths.lib_dir),
207
+ self_contained=options.self_contained,
208
+ release=options.release,
209
+ target_permissions=self.config.target_permissions,
210
+ installed_version=str(version),
211
+ )
212
+ self.registry.register(tracked)
213
+ except Exception as e: # pylint: disable=broad-except
214
+ logger.warning("Could not register app in registry: %s", e)
215
+
216
+ return version
217
+
218
+ finally:
219
+ if temp_staging_dir and temp_staging_dir.exists():
220
+ shutil.rmtree(temp_staging_dir, ignore_errors=True)
221
+
222
+ def prune_versions(self, keep_latest: int) -> list[semver.Version]:
223
+ """Prune older installed versions, keeping only the specified number of latest versions."""
224
+ installed = self.get_installed_versions()
225
+ if len(installed) <= keep_latest:
226
+ return []
227
+
228
+ removed = []
229
+ for v in installed[keep_latest:]:
230
+ version_dir = self.paths.versions_dir / str(v)
231
+ if version_dir.exists():
232
+ self.os_ops.remove_link_or_dir(version_dir)
233
+ removed.append(v)
234
+ return removed
235
+
236
+ def rollback(
237
+ self, target_version: str | None = None, previous: bool = False
238
+ ) -> semver.Version:
239
+ """Rollback to a previous installed version."""
240
+ installed = self.get_installed_versions()
241
+ if not installed:
242
+ raise AppManagerError("No installed versions found to rollback to.")
243
+
244
+ current = self.get_current_version()
245
+ selected_version: semver.Version | None = None
246
+
247
+ if previous:
248
+ # Find the version directly before the current version
249
+ if current and len(installed) > 1:
250
+ for idx, v in enumerate(installed):
251
+ if v == current and idx + 1 < len(installed):
252
+ selected_version = installed[idx + 1]
253
+ break
254
+ if not selected_version and len(installed) > 1:
255
+ selected_version = installed[1]
256
+ elif not selected_version:
257
+ raise AppManagerError(
258
+ "Cannot rollback with --previous: only one version is installed."
259
+ )
260
+ elif target_version:
261
+ target_v = parse_semver(target_version)
262
+ if not target_v:
263
+ raise AppManagerError(f"Invalid semver version: '{target_version}'")
264
+ if target_v not in installed:
265
+ raise AppManagerError(f"Target version '{target_version}' is not installed.")
266
+ selected_version = target_v
267
+ else:
268
+ raise AppManagerError("Must specify either a target version or --previous.")
269
+
270
+ # Switch current link
271
+ self.os_ops.update_current_link(
272
+ versions_dir=self.paths.versions_dir,
273
+ target_version=str(selected_version),
274
+ current_dir=self.paths.current_dir,
275
+ )
276
+
277
+ # Update binary launcher
278
+ target_current_exec = self.paths.current_dir / self.config.source_binary_name
279
+ self.os_ops.create_bin_launcher(
280
+ bin_dir=self.paths.bin_dir,
281
+ bin_name=self.config.binary_name,
282
+ target_executable=target_current_exec,
283
+ )
284
+
285
+ return selected_version
286
+
287
+ def uninstall(self, target_version: str | None = None, all_versions: bool = False) -> None:
288
+ """Uninstall a specific version or the entire application."""
289
+ if all_versions:
290
+ # Remove versions dir and current
291
+ if self.target_os == OperatingSystem.WINDOWS:
292
+ app_root = self.paths.lib_dir.parent
293
+ self.os_ops.remove_link_or_dir(app_root)
294
+ else:
295
+ self.os_ops.remove_link_or_dir(self.paths.lib_dir)
296
+
297
+ # Remove launchers
298
+ cmd_launcher = self.paths.bin_dir / f"{self.config.binary_name}.cmd"
299
+ posix_launcher = self.paths.bin_dir / self.config.binary_name
300
+ self.os_ops.remove_link_or_dir(cmd_launcher)
301
+ self.os_ops.remove_link_or_dir(posix_launcher)
302
+
303
+ # Remove from tracking registry
304
+ try:
305
+ self.registry.remove(self.config.binary_name)
306
+ except Exception as e: # pylint: disable=broad-except
307
+ logger.warning("Could not remove app from registry: %s", e)
308
+ return
309
+
310
+ if target_version:
311
+ target_v = parse_semver(target_version)
312
+ if not target_v:
313
+ raise AppManagerError(f"Invalid semver version: '{target_version}'")
314
+
315
+ target_dir = self.paths.versions_dir / str(target_v)
316
+ if not target_dir.exists():
317
+ raise AppManagerError(f"Version '{target_version}' is not installed.")
318
+
319
+ current = self.get_current_version()
320
+
321
+ self.os_ops.remove_link_or_dir(target_dir)
322
+
323
+ # If the current link pointed to the deleted version, fallback to newest remaining
324
+ if current == target_v:
325
+ remaining = self.get_installed_versions()
326
+ if remaining:
327
+ self.rollback(target_version=str(remaining[0]))
328
+ else:
329
+ self.os_ops.remove_link_or_dir(self.paths.current_dir)
330
+ cmd_launcher = self.paths.bin_dir / f"{self.config.binary_name}.cmd"
331
+ posix_launcher = self.paths.bin_dir / self.config.binary_name
332
+ self.os_ops.remove_link_or_dir(cmd_launcher)
333
+ self.os_ops.remove_link_or_dir(posix_launcher)
334
+ return
335
+
336
+ raise AppManagerError("Must specify either --target-version or --all for uninstall.")
337
+
338
+
339
+ __all__ = ["AppManager", "AppManagerError", "VersionRegressionError"]
@@ -0,0 +1,23 @@
1
+ """User-facing CLI output helpers."""
2
+
3
+ import sys
4
+ from typing import TextIO
5
+
6
+
7
+ def write(message: str, stream: TextIO | None = None) -> None:
8
+ """Write a user-facing line to the selected stream."""
9
+ print(message, file=stream or sys.stdout)
10
+
11
+
12
+ def error(message: str) -> None:
13
+ """Write a user-facing error line to stderr."""
14
+ write(message, stream=sys.stderr)
15
+
16
+
17
+ def progress(message: str, quiet: bool = False) -> None:
18
+ """Write progress output unless quiet mode is enabled."""
19
+ if not quiet:
20
+ write(message)
21
+
22
+
23
+ __all__ = ["error", "progress", "write"]
File without changes
@@ -0,0 +1,59 @@
1
+ """Retrieve and display details about a .NET application."""
2
+
3
+ import argparse
4
+
5
+ from net_install_manager.runtime_config import RuntimeInfo
6
+ from net_install_manager.registry import AppRegistry
7
+ from net_install_manager.cli_output import write, error
8
+
9
+
10
+ def execute(
11
+ args: argparse.Namespace,
12
+ registry: AppRegistry | None = None,
13
+ runtime: RuntimeInfo | None = None,
14
+ ) -> int:
15
+ """Retrieve and display details about a ninman-managed application."""
16
+ runtime = runtime or RuntimeInfo.current(system_wide=getattr(args, "system", False))
17
+ registry = registry or AppRegistry(runtime=runtime)
18
+ app_name = args.app_name
19
+
20
+ details = registry.get(app_name)
21
+ if not details:
22
+ error(f"No details found for application '{app_name}'.")
23
+ return 1
24
+
25
+ write(f"Details for application '{app_name}':")
26
+
27
+ write(f" Lib path: {details.lib_path}")
28
+ write(f" Version: {details.installed_version}")
29
+ write(f" Binary: {details.binary_name}")
30
+ write(f" Source binary: {details.source_binary_name}")
31
+
32
+ write(" Source:")
33
+ if details.source.git_info:
34
+ write(" Source type: Git Repository Source")
35
+ write(f" Clone URL: {details.source.git_info.clone_url}")
36
+ write(f" Subpath: {details.source.git_info.subpath}")
37
+ write(f" Ref: {details.source.git_info.ref}")
38
+ elif details.source.release_info:
39
+ write(" Source type: GitHub Release Artifact")
40
+ write(f" Owner: {details.source.release_info.owner}")
41
+ write(f" Repo: {details.source.release_info.repo}")
42
+ write(f" Asset pattern: {details.source.release_info.asset_pattern}")
43
+ write(f" Tag: {details.source.release_info.tag}")
44
+ elif details.source.local_path:
45
+ write(" Source type: Local Path")
46
+ write(f" Path: {details.source.local_path}")
47
+
48
+ return 0
49
+
50
+
51
+ def register_app_details_command(
52
+ subparsers: argparse._SubParsersAction,
53
+ ) -> argparse.ArgumentParser:
54
+ """Register the command to display application details."""
55
+ parser = subparsers.add_parser(
56
+ "app-details", help="Display details about a ninman-managed application"
57
+ )
58
+ parser.add_argument("app_name", help="Name of the ninman-managed application")
59
+ return parser
@@ -0,0 +1,114 @@
1
+ """Install command for the Net Install Manager application."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from net_install_manager.app_manager import AppManager, VersionRegressionError
7
+ from net_install_manager.cli_output import error, write
8
+ from net_install_manager.runtime_config import InstallOptions
9
+ from net_install_manager.utilities.git_tools import is_git_url, parse_git_source
10
+ from net_install_manager.utilities.install_tools import InstallSource
11
+
12
+
13
+ def parse_options(args: argparse.Namespace) -> InstallOptions:
14
+ """Parse InstallOptions from command-line arguments."""
15
+ return InstallOptions(
16
+ self_contained=getattr(args, "self_contained", False),
17
+ release=not getattr(args, "debug", False),
18
+ keep_latest=getattr(args, "versions_to_keep", None),
19
+ force=getattr(args, "force", False),
20
+ explicit_version=getattr(args, "app_version", None),
21
+ quiet=getattr(args, "quiet", False),
22
+ )
23
+
24
+
25
+ def parse_source(args: argparse.Namespace, manager: AppManager) -> InstallSource:
26
+ """Parse the install source and project selector from CLI arguments."""
27
+ source = args.source
28
+ project_path = getattr(args, "project", None)
29
+ if source and is_git_url(str(source)):
30
+ git_info = parse_git_source(str(source), project_override=project_path)
31
+ return InstallSource(
32
+ git_info=git_info,
33
+ project_path=git_info.subpath,
34
+ )
35
+
36
+ source_path = source or manager.config.csproj_path or Path.cwd()
37
+ return InstallSource(local_path=str(source_path), project_path=project_path)
38
+
39
+
40
+ def execute(args: argparse.Namespace, manager: AppManager):
41
+ """Execute the install command."""
42
+ if args.versions_to_keep is not None and args.versions_to_keep < 1:
43
+ error("The number of versions to keep must be at least 1.")
44
+ return 1
45
+
46
+ try:
47
+ options = parse_options(args)
48
+ installed_version = manager.install(
49
+ source=parse_source(args, manager),
50
+ options=options,
51
+ )
52
+ write(
53
+ f"Successfully installed version {installed_version} of '{manager.config.binary_name}'."
54
+ )
55
+ return 0
56
+ except VersionRegressionError as e:
57
+ error(f"Error: {e}")
58
+ return 6
59
+ except Exception as e: # pylint: disable=broad-except
60
+ error(f"Installation failed: {e}")
61
+ return 1
62
+
63
+
64
+ def register_install_command(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
65
+ """Register the install command arguments."""
66
+ parser = subparsers.add_parser("install", help="Install a .NET application")
67
+ parser.add_argument(
68
+ "source",
69
+ nargs="?",
70
+ default=None,
71
+ help="Path to .csproj file, project folder, precompiled directory, or Git/GitHub URL",
72
+ )
73
+ parser.add_argument(
74
+ "-p",
75
+ "--project",
76
+ "--project-path",
77
+ dest="project",
78
+ default=None,
79
+ help="Path to the target .csproj file within the repository or directory",
80
+ )
81
+ parser.add_argument(
82
+ "--self-contained",
83
+ action="store_true",
84
+ help="Install as self-contained application (includes .NET runtime)",
85
+ )
86
+ parser.add_argument(
87
+ "--debug",
88
+ action="store_true",
89
+ help="Build using Debug configuration instead of Release",
90
+ )
91
+ parser.add_argument(
92
+ "-k",
93
+ "--versions-to-keep",
94
+ type=int,
95
+ default=None,
96
+ help="Number of previous versions to keep",
97
+ )
98
+ parser.add_argument(
99
+ "-f",
100
+ "--force",
101
+ action="store_true",
102
+ help="Force reinstallation even if version is already installed or older",
103
+ )
104
+ parser.add_argument(
105
+ "-v",
106
+ "--app-version",
107
+ type=str,
108
+ default=None,
109
+ help="Explicit version string (overrides automatic detection)",
110
+ )
111
+ return parser
112
+
113
+
114
+ __all__ = ["execute", "register_install_command"]