microlens-submit 0.16.3__tar.gz → 0.16.4__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.
Files changed (47) hide show
  1. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/CHANGELOG.md +24 -0
  2. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/CITATION.cff +2 -2
  3. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/PKG-INFO +13 -8
  4. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/README.md +11 -7
  5. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/__init__.py +1 -1
  6. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/export.py +49 -8
  7. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/init.py +16 -12
  8. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/solutions.py +87 -34
  9. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/main.py +2 -1
  10. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/error_messages.py +2 -1
  11. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/models/solution.py +6 -0
  12. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/models/submission.py +33 -2
  13. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/tier_validation.py +41 -35
  14. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/utils.py +8 -1
  15. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/validate_parameters.py +46 -0
  16. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/PKG-INFO +13 -8
  17. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/requires.txt +1 -0
  18. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/pyproject.toml +2 -1
  19. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/setup.py +1 -1
  20. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/tests/test_cli.py +7 -7
  21. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/tests/test_dossier_generation.py +1 -1
  22. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/tests/test_dossier_pages.py +1 -1
  23. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/tests/test_tier_validation.py +57 -56
  24. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/LICENSE +0 -0
  25. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/MANIFEST.in +0 -0
  26. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/assets/github-desktop_logo.png +0 -0
  27. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/assets/rges-pit_logo.png +0 -0
  28. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/__init__.py +0 -0
  29. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/__main__.py +0 -0
  30. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/__init__.py +0 -0
  31. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/dossier.py +0 -0
  32. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/cli/commands/validation.py +0 -0
  33. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/__init__.py +0 -0
  34. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/dashboard.py +0 -0
  35. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/event_page.py +0 -0
  36. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/full_report.py +0 -0
  37. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/solution_page.py +0 -0
  38. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/dossier/utils.py +0 -0
  39. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/models/__init__.py +0 -0
  40. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/models/event.py +0 -0
  41. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit/text_symbols.py +0 -0
  42. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/SOURCES.txt +0 -0
  43. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/dependency_links.txt +0 -0
  44. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/entry_points.txt +0 -0
  45. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/microlens_submit.egg-info/top_level.txt +0 -0
  46. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/setup.cfg +0 -0
  47. {microlens_submit-0.16.3 → microlens_submit-0.16.4}/tests/test_api.py +0 -0
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.16.4] - 2026-01-14
9
+
10
+ ### Added
11
+ - Added `remove-event`/`remove-solution` CLI commands and `remove_event`/`remove_solution` API helpers with a `--force` guard for hard deletes.
12
+ - Added `git_dir` metadata plus `set-git-dir` to capture Git info when the repo lives outside the submission directory.
13
+ - Added optional GPU fields in `hardware_info` (`gpu.model`, `gpu.count`, `gpu.memory_gb`) alongside platform/OS capture.
14
+ - Added non-Nexus hardware auto-fill using `psutil` for CPU and memory details.
15
+ - Added conda-forge recipe (`conda/recipe/meta.yaml`) to the version bump script (`scripts/bump_version`).
16
+ - Added sha256 update in `conda/recipe/meta.yaml` to the release workflow.
17
+ - Added a workflow release job to copy the local updated version on the conda-forge recipe to the feedstock fork (`AmberLee2427/microlens-submit-feedstock`) and send a PR, after PyPI release.
18
+
19
+
20
+ ### Changed
21
+ - Updated tiers to `beginner`/`experienced`; event ID validation now uses inclusive ranges and 3-digit IDs for `2018-test`.
22
+ - CLI numeric parsing now accepts leading decimals like `.001`.
23
+ - Clarified quickstart/tutorial guidance around working directories and hardware info requirements.
24
+
25
+
26
+ ### Fixed
27
+ - CSV import now skips blank rows to avoid NoneType parsing errors.
28
+ - Validation messaging now highlights missing bands when flux parameters are provided.
29
+ - Improved Windows notes editor fallback for better default editor selection.
30
+
31
+
8
32
  ## [0.16.3] - 2025-10-28
9
33
 
10
34
  ### Added
@@ -1,9 +1,9 @@
1
1
  cff-version: 1.2.0
2
2
  message: "If you use microlens-submit, please cite it as below."
3
3
  title: "microlens-submit"
4
- version: "0.16.3"
4
+ version: "0.16.4"
5
5
  authors:
6
6
  - family-names: Malpas
7
7
  given-names: Amber
8
8
  url: "https://github.com/AmberLee2427/microlens-submit"
9
- doi: "10.5281/zenodo.17468488"
9
+ doi: "10.5281/zenodo.18246117"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microlens-submit
3
- Version: 0.16.3
3
+ Version: 0.16.4
4
4
  Summary: A tool for managing and submitting microlensing solutions
5
5
  Home-page: https://github.com/AmberLee2427/microlens-submit
6
6
  Author: Amber Malpas
@@ -29,6 +29,7 @@ Requires-Dist: typer[all]>=0.9.0
29
29
  Requires-Dist: rich>=13.0.0
30
30
  Requires-Dist: pyyaml>=6.0
31
31
  Requires-Dist: markdown>=3.4.0
32
+ Requires-Dist: psutil>=5.9.0
32
33
  Requires-Dist: importlib_resources>=1.0.0; python_version < "3.9"
33
34
  Provides-Extra: dev
34
35
  Requires-Dist: pytest; extra == "dev"
@@ -97,15 +98,19 @@ You can pass ``--no-color`` to any command if your terminal does not support ANS
97
98
  1. Initialize your project:
98
99
 
99
100
  ```bash
100
- microlens-submit init --team-name "Planet Pounders" --tier "advanced"
101
- # if a project directory was provided to `init`, you should now `cd` into that project
101
+ microlens-submit init --team-name "Planet Pounders" --tier "experienced" ./my_submission
102
+ cd ./my_submission
102
103
  ```
103
104
 
104
- To pass validation, you need to have provided a `repo_url` and `hardware_info` to the project and have a git project initialized in your sumission-project directory.
105
+ If you prefer to initialize inside an existing folder, run `microlens-submit init` without a path after `cd` into it.
106
+
107
+ To pass validation, you need to have provided a `repo_url` and `hardware_info` to the project and have a git project initialized in your sumission-project directory. On Roman Nexus, you can use `microlens-submit nexus-init` to auto-populate hardware info.
105
108
 
106
109
  ```bash
107
110
  microlens-submit set-repo-url <url> ./
108
111
  microlens-submit set-hardware-info --cpu-details "intel i7 xxx" --ram-gb 32 ./
112
+ # if your git repo lives elsewhere:
113
+ microlens-submit set-git-dir /path/to/repo ./
109
114
  ```
110
115
  2. Add a new solution to an event:
111
116
 
@@ -152,7 +157,7 @@ import microlens_submit
152
157
  # Load or create the project
153
158
  sub = microlens_submit.load(project_path="./my_challenge_submission")
154
159
  sub.team_name = "Planet Pounders"
155
- sub.tier = "advanced"
160
+ sub.tier = "experienced"
156
161
 
157
162
  # Get an event and add a solution
158
163
  evt = sub.get_event("ogle-2025-blg-0042")
@@ -201,15 +206,15 @@ import this file directly.
201
206
 
202
207
  Bibtex:
203
208
  ```
204
- @software{malpas_2025_17468488,
209
+ @software{malpas_2025_18246117,
205
210
  author = {Malpas, Amber},
206
211
  title = {microlens-submit},
207
212
  month = oct,
208
213
  year = 2025,
209
214
  publisher = {Zenodo},
210
215
  version = {v0.16.3},
211
- doi = {10.5281/zenodo.17468488},
212
- url = {https://doi.org/10.5281/zenodo.17468488},
216
+ doi = {10.5281/zenodo.18246117},
217
+ url = {https://doi.org/10.5281/zenodo.18246117},
213
218
  }
214
219
  ```
215
220
 
@@ -48,15 +48,19 @@ You can pass ``--no-color`` to any command if your terminal does not support ANS
48
48
  1. Initialize your project:
49
49
 
50
50
  ```bash
51
- microlens-submit init --team-name "Planet Pounders" --tier "advanced"
52
- # if a project directory was provided to `init`, you should now `cd` into that project
51
+ microlens-submit init --team-name "Planet Pounders" --tier "experienced" ./my_submission
52
+ cd ./my_submission
53
53
  ```
54
54
 
55
- To pass validation, you need to have provided a `repo_url` and `hardware_info` to the project and have a git project initialized in your sumission-project directory.
55
+ If you prefer to initialize inside an existing folder, run `microlens-submit init` without a path after `cd` into it.
56
+
57
+ To pass validation, you need to have provided a `repo_url` and `hardware_info` to the project and have a git project initialized in your sumission-project directory. On Roman Nexus, you can use `microlens-submit nexus-init` to auto-populate hardware info.
56
58
 
57
59
  ```bash
58
60
  microlens-submit set-repo-url <url> ./
59
61
  microlens-submit set-hardware-info --cpu-details "intel i7 xxx" --ram-gb 32 ./
62
+ # if your git repo lives elsewhere:
63
+ microlens-submit set-git-dir /path/to/repo ./
60
64
  ```
61
65
  2. Add a new solution to an event:
62
66
 
@@ -103,7 +107,7 @@ import microlens_submit
103
107
  # Load or create the project
104
108
  sub = microlens_submit.load(project_path="./my_challenge_submission")
105
109
  sub.team_name = "Planet Pounders"
106
- sub.tier = "advanced"
110
+ sub.tier = "experienced"
107
111
 
108
112
  # Get an event and add a solution
109
113
  evt = sub.get_event("ogle-2025-blg-0042")
@@ -152,15 +156,15 @@ import this file directly.
152
156
 
153
157
  Bibtex:
154
158
  ```
155
- @software{malpas_2025_17468488,
159
+ @software{malpas_2025_18246117,
156
160
  author = {Malpas, Amber},
157
161
  title = {microlens-submit},
158
162
  month = oct,
159
163
  year = 2025,
160
164
  publisher = {Zenodo},
161
165
  version = {v0.16.3},
162
- doi = {10.5281/zenodo.17468488},
163
- url = {https://doi.org/10.5281/zenodo.17468488},
166
+ doi = {10.5281/zenodo.18246117},
167
+ url = {https://doi.org/10.5281/zenodo.18246117},
164
168
  }
165
169
  ```
166
170
 
@@ -5,7 +5,7 @@ validate, and export a challenge submission using either the Python API or
5
5
  the command line interface.
6
6
  """
7
7
 
8
- __version__ = "0.16.3"
8
+ __version__ = "0.16.4"
9
9
 
10
10
  from .models import Event, Solution, Submission
11
11
  from .utils import load
@@ -42,10 +42,13 @@ def export(
42
42
 
43
43
  def remove_event(
44
44
  event_id: str,
45
- force: bool = typer.Option(False, "--force", help="Force removal even if event has saved solutions"),
45
+ force: bool = typer.Option(False, "--force", help="Required to remove an event (prevents accidents)"),
46
46
  project_path: Path = typer.Argument(Path("."), help="Project directory"),
47
47
  ) -> None:
48
- """Remove an entire event and all its solutions from the submission."""
48
+ """Remove an entire event and all its solutions from the submission.
49
+
50
+ This action is destructive and requires --force to proceed.
51
+ """
49
52
  submission = load(str(project_path))
50
53
 
51
54
  if event_id not in submission.events:
@@ -57,13 +60,11 @@ def remove_event(
57
60
 
58
61
  if not force:
59
62
  typer.echo(
60
- f"{symbol('warning')} This will permanently remove event '{event_id}' and all {solution_count} solutions."
63
+ f"{symbol('warning')} Refusing to remove event '{event_id}' without --force "
64
+ f"({solution_count} solutions)."
61
65
  )
62
- typer.echo(" This action cannot be undone.")
63
- confirm = typer.confirm("Are you sure you want to continue?")
64
- if not confirm:
65
- typer.echo(f"{symbol('error')} Operation cancelled")
66
- raise typer.Exit(0)
66
+ typer.echo(f"{symbol('hint')} Consider deactivating solutions instead, or re-run with --force to proceed.")
67
+ raise typer.Exit(0)
67
68
 
68
69
  try:
69
70
  removed = submission.remove_event(event_id, force=force)
@@ -94,11 +95,33 @@ def set_repo_url(
94
95
  )
95
96
 
96
97
 
98
+ def set_git_dir(
99
+ git_dir: Path = typer.Argument(..., help="Path to the git working tree"),
100
+ project_path: Path = typer.Argument(Path("."), help="Project directory"),
101
+ ) -> None:
102
+ """Set or update the git working tree path in the submission metadata."""
103
+ sub = load(str(project_path))
104
+ git_dir_path = git_dir.expanduser().resolve()
105
+ if not git_dir_path.exists():
106
+ raise typer.BadParameter(f"git_dir does not exist: {git_dir_path}")
107
+ sub.git_dir = str(git_dir_path)
108
+ sub.save()
109
+ console.print(
110
+ Panel(
111
+ f"Set git_dir to {git_dir_path} in {project_path}/submission.json",
112
+ style="bold green",
113
+ )
114
+ )
115
+
116
+
97
117
  def set_hardware_info(
98
118
  cpu: Optional[str] = typer.Option(None, "--cpu", help="CPU model/description"),
99
119
  cpu_details: Optional[str] = typer.Option(None, "--cpu-details", help="Detailed CPU information"),
100
120
  memory_gb: Optional[float] = typer.Option(None, "--memory-gb", help="Memory in GB"),
101
121
  ram_gb: Optional[float] = typer.Option(None, "--ram-gb", help="RAM in GB (alternative to --memory-gb)"),
122
+ gpu: Optional[str] = typer.Option(None, "--gpu", help="GPU model/description"),
123
+ gpu_count: Optional[int] = typer.Option(None, "--gpu-count", help="Number of GPUs"),
124
+ gpu_memory_gb: Optional[float] = typer.Option(None, "--gpu-memory-gb", help="GPU memory per device in GB"),
102
125
  platform: Optional[str] = typer.Option(
103
126
  None,
104
127
  "--platform",
@@ -153,6 +176,24 @@ def set_hardware_info(
153
176
  changes.append(f"Set nexus_image: {nexus_image}")
154
177
  sub.hardware_info["nexus_image"] = nexus_image
155
178
 
179
+ if any(value is not None for value in (gpu, gpu_count, gpu_memory_gb)):
180
+ gpu_info = sub.hardware_info.get("gpu")
181
+ if not isinstance(gpu_info, dict):
182
+ gpu_info = {}
183
+ if gpu is not None:
184
+ if gpu_info.get("model") != gpu:
185
+ changes.append(f"Set gpu.model: {gpu}")
186
+ gpu_info["model"] = gpu
187
+ if gpu_count is not None:
188
+ if gpu_info.get("count") != gpu_count:
189
+ changes.append(f"Set gpu.count: {gpu_count}")
190
+ gpu_info["count"] = gpu_count
191
+ if gpu_memory_gb is not None:
192
+ if gpu_info.get("memory_gb") != gpu_memory_gb:
193
+ changes.append(f"Set gpu.memory_gb: {gpu_memory_gb}")
194
+ gpu_info["memory_gb"] = gpu_memory_gb
195
+ sub.hardware_info["gpu"] = gpu_info
196
+
156
197
  # Show dry run results
157
198
  if dry_run:
158
199
  if changes:
@@ -17,6 +17,7 @@ def init(
17
17
  team_name: str = typer.Option(..., help="Team name"),
18
18
  tier: str = typer.Option(..., help="Challenge tier"),
19
19
  project_path: Path = typer.Argument(Path("."), help="Project directory"),
20
+ show_warnings: bool = True,
20
21
  ) -> None:
21
22
  """Create a new submission project in the specified directory.
22
23
 
@@ -29,7 +30,7 @@ def init(
29
30
 
30
31
  Args:
31
32
  team_name: Name of the participating team (e.g., "Team Alpha").
32
- tier: Challenge tier level (e.g., "basic", "advanced").
33
+ tier: Challenge tier level (e.g., "beginner", "experienced").
33
34
  project_path: Directory where the project will be created.
34
35
  Defaults to current directory if not specified.
35
36
 
@@ -38,10 +39,10 @@ def init(
38
39
 
39
40
  Example:
40
41
  # Create project in current directory
41
- microlens-submit init --team-name "Team Alpha" --tier "advanced"
42
+ microlens-submit init --team-name "Team Alpha" --tier "experienced"
42
43
 
43
44
  # Create project in specific directory
44
- microlens-submit init --team-name "Team Beta" --tier "basic" ./my_submission
45
+ microlens-submit init --team-name "Team Beta" --tier "beginner" ./my_submission
45
46
 
46
47
  # Project structure created:
47
48
  # ./my_submission/
@@ -100,12 +101,15 @@ def init(
100
101
  )
101
102
 
102
103
  # Run warnings-only validation
103
- warnings = sub.run_validation_warnings()
104
- if warnings:
105
- console.print(f"[yellow]{symbol('warning')} Project initialized with warnings:[/yellow]")
106
- for warning in warnings:
107
- console.print(f" [yellow]• {warning}[/yellow]")
108
- console.print(f"[yellow]{symbol('hint')} These warnings will become errors when saving or exporting.[/yellow]")
104
+ if show_warnings:
105
+ warnings = sub.run_validation_warnings()
106
+ if warnings:
107
+ console.print(f"[yellow]{symbol('warning')} Project initialized with warnings:[/yellow]")
108
+ for warning in warnings:
109
+ console.print(f" [yellow]{warning}[/yellow]")
110
+ console.print(
111
+ f"[yellow]{symbol('hint')} These warnings will become errors when saving or exporting.[/yellow]"
112
+ )
109
113
 
110
114
  # Try to save, but don't fail if there are validation errors
111
115
  try:
@@ -134,13 +138,13 @@ def nexus_init(
134
138
 
135
139
  Args:
136
140
  team_name: Name of the participating team (e.g., "Team Alpha").
137
- tier: Challenge tier level (e.g., "basic", "advanced").
141
+ tier: Challenge tier level (e.g., "beginner", "experienced").
138
142
  project_path: Directory where the project will be created.
139
143
  Defaults to current directory if not specified.
140
144
 
141
145
  Example:
142
146
  # Initialize project with Nexus platform info
143
- microlens-submit nexus-init --team-name "Team Alpha" --tier "advanced" ./project
147
+ microlens-submit nexus-init --team-name "Team Alpha" --tier "experienced" ./project
144
148
 
145
149
  # This will automatically detect:
146
150
  # - CPU model from /proc/cpuinfo
@@ -152,7 +156,7 @@ def nexus_init(
152
156
  environment. It will silently skip any environment information that
153
157
  cannot be detected (e.g., if running outside of Nexus).
154
158
  """
155
- init(team_name=team_name, tier=tier, project_path=project_path)
159
+ init(team_name=team_name, tier=tier, project_path=project_path, show_warnings=False)
156
160
  sub = load(str(project_path))
157
161
  sub.autofill_nexus_info()
158
162
 
@@ -2,8 +2,13 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import sys
5
10
  from pathlib import Path
6
- from typing import Dict, List, Optional, Tuple
11
+ from typing import Any, Dict, List, Optional, Tuple
7
12
 
8
13
  import typer
9
14
  from rich.console import Console
@@ -16,6 +21,38 @@ from microlens_submit.utils import import_solutions_from_csv, load
16
21
  console = Console()
17
22
 
18
23
 
24
+ _NUMERIC_RE = re.compile(r"^[+-]?((\\d+(\\.\\d*)?)|(\\.\\d+))([eE][+-]?\\d+)?$")
25
+
26
+
27
+ def _parse_cli_value(value: str) -> Any:
28
+ """Parse a CLI value using JSON, with a numeric fallback for .001-style input."""
29
+ try:
30
+ return json.loads(value)
31
+ except json.JSONDecodeError:
32
+ if _NUMERIC_RE.match(value.strip()):
33
+ try:
34
+ if re.match(r"^[+-]?\\d+$", value.strip()):
35
+ return int(value)
36
+ return float(value)
37
+ except ValueError:
38
+ pass
39
+ return value
40
+
41
+
42
+ def _run_editor(editor_cmd: str, notes_file: Path) -> bool:
43
+ parts = shlex.split(editor_cmd)
44
+ if not parts:
45
+ return False
46
+ if os.name == "nt" and parts[0].lower() in ("code", "code.exe"):
47
+ if "--wait" not in parts and "-w" not in parts:
48
+ parts.append("--wait")
49
+ try:
50
+ subprocess.run(parts + [str(notes_file)], check=False)
51
+ return True
52
+ except FileNotFoundError:
53
+ return False
54
+
55
+
19
56
  def _parse_pairs(pairs: Optional[List[str]]) -> Optional[Dict]:
20
57
  """Convert CLI key=value options into a dictionary."""
21
58
  if not pairs:
@@ -25,10 +62,7 @@ def _parse_pairs(pairs: Optional[List[str]]) -> Optional[Dict]:
25
62
  if "=" not in item:
26
63
  raise typer.BadParameter(f"Invalid format: {item}")
27
64
  key, value = item.split("=", 1)
28
- try:
29
- out[key] = json.loads(value)
30
- except json.JSONDecodeError:
31
- out[key] = value
65
+ out[key] = _parse_cli_value(value)
32
66
  return out
33
67
 
34
68
 
@@ -219,10 +253,7 @@ def add_solution(
219
253
  if "=" not in p:
220
254
  raise typer.BadParameter(f"Invalid parameter format: {p}")
221
255
  key, value = p.split("=", 1)
222
- try:
223
- params[key] = json.loads(value)
224
- except json.JSONDecodeError:
225
- params[key] = value
256
+ params[key] = _parse_cli_value(value)
226
257
  allowed_model_types = [
227
258
  "1S1L",
228
259
  "1S2L",
@@ -254,7 +285,7 @@ def add_solution(
254
285
  sol.relative_probability = relative_probability
255
286
  sol.n_data_points = n_data_points
256
287
  if cpu_hours is not None or wall_time_hours is not None:
257
- sol.set_compute_info(cpu_hours=cpu_hours, wall_time_hours=wall_time_hours)
288
+ sol.set_compute_info(cpu_hours=cpu_hours, wall_time_hours=wall_time_hours, git_dir=sub.git_dir)
258
289
  sol.lightcurve_plot_path = str(lightcurve_plot_path) if lightcurve_plot_path else None
259
290
  sol.lens_plane_plot_path = str(lens_plane_plot_path) if lens_plane_plot_path else None
260
291
  # Handle notes file logic
@@ -368,10 +399,13 @@ def activate(
368
399
 
369
400
  def remove_solution(
370
401
  solution_id: str,
371
- force: bool = typer.Option(False, "--force", help="Force removal of saved solutions (use with caution)"),
402
+ force: bool = typer.Option(False, "--force", help="Required to remove a solution (prevents accidents)"),
372
403
  project_path: Path = typer.Argument(Path("."), help="Project directory"),
373
404
  ) -> None:
374
- """Completely remove a solution from the submission."""
405
+ """Completely remove a solution from the submission.
406
+
407
+ This action is destructive and requires --force to proceed.
408
+ """
375
409
  submission = load(project_path)
376
410
 
377
411
  # Find the solution across all events
@@ -387,6 +421,16 @@ def remove_solution(
387
421
  console.print(f"[red]Error: Solution {solution_id} not found[/red]")
388
422
  raise typer.Exit(1)
389
423
 
424
+ if not force:
425
+ console.print(
426
+ f"[yellow]{symbol('warning')} Refusing to remove solution {solution_id[:8]}... without --force.[/yellow]"
427
+ )
428
+ console.print(
429
+ f"[blue]{symbol('hint')} Consider using deactivate to keep the solution, "
430
+ "or re-run with --force to proceed.[/blue]"
431
+ )
432
+ raise typer.Exit(0)
433
+
390
434
  try:
391
435
  removed = submission.events[event_id].remove_solution(solution_id, force=force)
392
436
  if removed:
@@ -561,16 +605,14 @@ def edit_solution(
561
605
  target_solution.set_compute_info(
562
606
  cpu_hours=cpu_hours if cpu_hours is not None else old_cpu,
563
607
  wall_time_hours=(wall_time_hours if wall_time_hours is not None else old_wall),
608
+ git_dir=sub.git_dir,
564
609
  )
565
610
  if param:
566
611
  for p in param:
567
612
  if "=" not in p:
568
613
  raise typer.BadParameter(f"Invalid parameter format: {p}")
569
614
  key, value = p.split("=", 1)
570
- try:
571
- new_value = json.loads(value)
572
- except json.JSONDecodeError:
573
- new_value = value
615
+ new_value = _parse_cli_value(value)
574
616
  old_value = target_solution.parameters.get(key)
575
617
  if old_value != new_value:
576
618
  changes.append(f"Update parameter {key}: {old_value} {arrow} {new_value}")
@@ -582,10 +624,7 @@ def edit_solution(
582
624
  if "=" not in p:
583
625
  raise typer.BadParameter(f"Invalid uncertainty format: {p}")
584
626
  key, value = p.split("=", 1)
585
- try:
586
- new_value = json.loads(value)
587
- except json.JSONDecodeError:
588
- new_value = value
627
+ new_value = _parse_cli_value(value)
589
628
  old_value = target_solution.parameter_uncertainties.get(key)
590
629
  if old_value != new_value:
591
630
  changes.append(f"Update uncertainty {key}: {old_value} {arrow} {new_value}")
@@ -636,21 +675,35 @@ def edit_notes(
636
675
  notes_file.parent.mkdir(parents=True, exist_ok=True)
637
676
  if not notes_file.exists():
638
677
  notes_file.write_text("", encoding="utf-8")
639
- editor = os.environ.get("EDITOR", None)
640
- if editor:
641
- os.system(f'{editor} "{notes_file}"')
678
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
679
+ if editor and _run_editor(editor, notes_file):
680
+ return
681
+ fallbacks = ["nano", "vi", "vim", "code"]
682
+ if os.name == "nt":
683
+ fallbacks = ["code", "notepad", "notepad.exe"]
684
+ for fallback in fallbacks:
685
+ if shutil.which(fallback):
686
+ if _run_editor(fallback, notes_file):
687
+ return
688
+ if os.name == "nt":
689
+ try:
690
+ os.startfile(notes_file) # type: ignore[attr-defined]
691
+ return
692
+ except OSError:
693
+ pass
694
+ elif sys.platform == "darwin":
695
+ if shutil.which("open"):
696
+ subprocess.run(["open", "-W", str(notes_file)], check=False)
697
+ return
642
698
  else:
643
- # Try nano, then vi
644
- for fallback in ["nano", "vi"]:
645
- if os.system(f"command -v {fallback} > /dev/null 2>&1") == 0:
646
- os.system(f'{fallback} "{notes_file}"')
647
- break
648
- else:
649
- console.print(
650
- f"Could not find an editor to open {notes_file}",
651
- style="bold red",
652
- )
653
- raise typer.Exit(code=1)
699
+ if shutil.which("xdg-open"):
700
+ subprocess.run(["xdg-open", str(notes_file)], check=False)
701
+ return
702
+ console.print(
703
+ f"Could not find an editor to open {notes_file}",
704
+ style="bold red",
705
+ )
706
+ raise typer.Exit(code=1)
654
707
  return
655
708
  console.print(f"Solution {solution_id} not found", style="bold red")
656
709
  raise typer.Exit(code=1)
@@ -17,7 +17,7 @@ and scripted usage patterns.
17
17
 
18
18
  **Example Workflow:**
19
19
  # Initialize a new project
20
- microlens-submit init --team-name "Team Alpha" --tier "advanced" ./my_project
20
+ microlens-submit init --team-name "Team Alpha" --tier "experienced" ./my_project
21
21
 
22
22
  # Add a solution
23
23
  microlens-submit add-solution EVENT001 1S1L ./my_project \
@@ -117,4 +117,5 @@ app.command("generate-dossier")(dossier.generate_dossier)
117
117
  app.command("export")(export.export)
118
118
  app.command("remove-event")(export.remove_event)
119
119
  app.command("set-repo-url")(export.set_repo_url)
120
+ app.command("set-git-dir")(export.set_git_dir)
120
121
  app.command("set-hardware-info")(export.set_hardware_info)
@@ -76,7 +76,8 @@ def get_parameter_suggestions(model_type: str, user_param: str) -> List[str]:
76
76
  for canonical, typos in common_typos.items():
77
77
  for typo in typos:
78
78
  if user_param.lower() == typo.lower():
79
- suggestions.append(f"Did you mean '{canonical}' instead of '{user_param}'?")
79
+ if user_param != canonical:
80
+ suggestions.append(f"Did you mean '{canonical}' instead of '{user_param}'?")
80
81
  # Also suggest case sensitivity if relevant
81
82
  for canonical in common_typos.keys():
82
83
  if user_param.lower() == canonical.lower() and user_param != canonical:
@@ -188,6 +188,7 @@ class Solution(BaseModel):
188
188
  self,
189
189
  cpu_hours: Optional[float] = None,
190
190
  wall_time_hours: Optional[float] = None,
191
+ git_dir: Optional[str] = None,
191
192
  ) -> None:
192
193
  """Record compute metadata and capture environment details.
193
194
 
@@ -199,6 +200,7 @@ class Solution(BaseModel):
199
200
  Args:
200
201
  cpu_hours: Total CPU time consumed by the model fit in hours.
201
202
  wall_time_hours: Real-world time consumed by the fit in hours.
203
+ git_dir: Optional path to the code repository for git metadata capture.
202
204
 
203
205
  Example:
204
206
  >>> solution = event.add_solution("1S1L", {"t0": 2459123.5, "u0": 0.1})
@@ -240,23 +242,27 @@ class Solution(BaseModel):
240
242
 
241
243
  # Capture Git repository information
242
244
  try:
245
+ git_cwd = Path(git_dir).expanduser().resolve() if git_dir else None
243
246
  commit = subprocess.run(
244
247
  ["git", "rev-parse", "HEAD"],
245
248
  capture_output=True,
246
249
  text=True,
247
250
  check=True,
251
+ cwd=git_cwd,
248
252
  ).stdout.strip()
249
253
  branch = subprocess.run(
250
254
  ["git", "rev-parse", "--abbrev-ref", "HEAD"],
251
255
  capture_output=True,
252
256
  text=True,
253
257
  check=True,
258
+ cwd=git_cwd,
254
259
  ).stdout.strip()
255
260
  status = subprocess.run(
256
261
  ["git", "status", "--porcelain"],
257
262
  capture_output=True,
258
263
  text=True,
259
264
  check=True,
265
+ cwd=git_cwd,
260
266
  ).stdout.strip()
261
267
  self.compute_info["git_info"] = {
262
268
  "commit": commit,