Skip to content

Commit 0e6693a

Browse files
Updates
1 parent bda061c commit 0e6693a

File tree

10 files changed

+757
-135
lines changed

10 files changed

+757
-135
lines changed

asimov/analysis.py

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ def make_config(self, filename, template_directory=None, dryrun=False):
701701
template_file = str(files("asimov").joinpath(f"configs/{template}"))
702702

703703
liq = Liquid(template_file)
704-
rendered = liq.render(production=self, analysis=self, config=config)
704+
rendered = liq.render(production=self, analysis=self, pipeline=pipeline, config=config)
705705
with open(filename, "w") as output_file:
706706
output_file.write(rendered)
707707

@@ -973,10 +973,8 @@ def __init__(self, subject, name, pipeline, status=None, comment=None, **kwargs)
973973
self.pipeline = pipeline.lower()
974974
self.pipeline = known_pipelines[pipeline.lower()](self)
975975

976-
if "needs" in self.meta:
977-
self._needs = cast(List[Any], self.meta.pop("needs"))
978-
else:
979-
self._needs = []
976+
needs_value = self.meta.pop("needs", None)
977+
self._needs = cast(List[Any], needs_value) if needs_value is not None else []
980978

981979
self.comment = kwargs.get("comment", None)
982980

@@ -1363,11 +1361,9 @@ def __init__(self, name, pipeline, ledger=None, **kwargs):
13631361
# except KeyError:
13641362
self.logger.warning(f"The pipeline {pipeline} could not be found.")
13651363

1366-
if "needs" in self.meta:
1367-
self._needs = cast(List[Any], self.meta.pop("needs"))
1368-
else:
1369-
self._needs = []
1370-
1364+
needs_value = self.meta.pop("needs", None)
1365+
self._needs = cast(List[Any], needs_value) if needs_value is not None else []
1366+
13711367
if "comment" in kwargs:
13721368
self.comment = kwargs["comment"]
13731369
else:
@@ -1648,6 +1644,83 @@ def rundir(self, value):
16481644
else:
16491645
self.meta["rundir"] = value
16501646

1647+
def html(self):
1648+
"""
1649+
Generate HTML card for this project analysis.
1650+
1651+
This method creates an HTML representation of the project analysis
1652+
for display in the asimov HTML report. It includes status information,
1653+
subject and analysis counts, and pipeline-specific content.
1654+
1655+
Returns
1656+
-------
1657+
str
1658+
HTML string representing this project analysis as a card.
1659+
"""
1660+
from asimov.event import status_map
1661+
1662+
# Status badge mapping
1663+
status_badge = status_map.get(self.status, "secondary")
1664+
1665+
card = f"""
1666+
<div class='project-analysis-card card event-data' id='project-{self.name}'>
1667+
<div class='card-header'>
1668+
<h3 class='card-title'>{self.name}</h3>
1669+
"""
1670+
1671+
if self.comment:
1672+
card += f" <p class='text-muted'>{self.comment}</p>\n"
1673+
1674+
# Status badge
1675+
card += f""" <span class='badge badge-{status_badge}'>{self.status}</span>
1676+
</div>
1677+
<div class='card-body'>
1678+
"""
1679+
1680+
# Show pipeline info
1681+
if self.pipeline:
1682+
pipeline_name = self.pipeline.name if hasattr(self.pipeline, 'name') else str(self.pipeline)
1683+
card += f" <p><strong>Pipeline:</strong> {pipeline_name}</p>\n"
1684+
1685+
# Show number of subjects
1686+
if hasattr(self, '_subjects') and self._subjects:
1687+
card += f" <p><strong>Subjects:</strong> {len(self._subjects)}</p>\n"
1688+
1689+
# Show number of analyses
1690+
if hasattr(self, 'analyses') and self.analyses:
1691+
card += f" <p><strong>Analyses:</strong> {len(self.analyses)}</p>\n"
1692+
1693+
# List subjects with links to event cards
1694+
if hasattr(self, '_subjects') and self._subjects:
1695+
card += """ <details>
1696+
<summary>View Subjects</summary>
1697+
<ul>
1698+
"""
1699+
for subject in self._subjects:
1700+
subject_name = subject.name if hasattr(subject, 'name') else str(subject)
1701+
card += f" <li><a href='#card-{subject_name}'>{subject_name}</a></li>\n"
1702+
1703+
card += """ </ul>
1704+
</details>
1705+
"""
1706+
1707+
# Pipeline-specific content (e.g., plots for CatalogPlotter)
1708+
if self.pipeline and hasattr(self.pipeline, 'html'):
1709+
try:
1710+
pipeline_html = self.pipeline.html()
1711+
card += pipeline_html
1712+
except Exception as e:
1713+
# Log but don't fail if pipeline HTML generation has issues
1714+
import logging
1715+
logger = logging.getLogger(__name__)
1716+
logger.warning(f"Failed to generate pipeline HTML for {self.name}: {e}")
1717+
1718+
card += """ </div>
1719+
</div>
1720+
"""
1721+
1722+
return card
1723+
16511724

16521725
class GravitationalWaveTransient(SimpleAnalysis):
16531726
"""

asimov/blueprints.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,41 @@ class Subject(Blueprint):
312312
default=None,
313313
description="A dictionary of prior configurations for the subject."
314314
)
315-
315+
316316
model_config = ConfigDict(extra='forbid')
317317

318318

319+
class ProjectAnalysis(Blueprint):
320+
"""
321+
A blueprint defining the configuration for a project-level analysis.
322+
323+
Project analyses operate across multiple subjects/events, useful for
324+
population studies, catalog plots, or any analysis requiring data from
325+
multiple events.
326+
"""
327+
name: str = pydantic.Field(
328+
description="The name of the project analysis."
329+
)
330+
pipeline: str = pydantic.Field(
331+
description="The pipeline to use for this project analysis."
332+
)
333+
status: str = pydantic.Field(
334+
default="ready",
335+
description="The initial status of the project analysis."
336+
)
337+
comment: str | None = pydantic.Field(
338+
default=None,
339+
description="A comment describing this project analysis."
340+
)
341+
subjects: list[str] | None = pydantic.Field(
342+
default=None,
343+
description="List of subject names to include in this project analysis."
344+
)
345+
analyses: list[dict | str] | None = pydantic.Field(
346+
default=None,
347+
description="Smart dependency specifications for which analyses to include."
348+
)
349+
350+
model_config = ConfigDict(extra='allow')
351+
352+

asimov/cli/application.py

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import os
7+
import re
78
import sys
89
from copy import deepcopy
910
from datetime import datetime
@@ -54,7 +55,47 @@ def get_ledger():
5455
return current_ledger
5556

5657

57-
def apply_page(file, event=None, ledger=None, update_page=False):
58+
def _raw_production_names(ledger, event_name):
59+
"""Return the set of production names for an event from raw ledger data.
60+
61+
Reads directly from ``ledger.events`` (a plain dict) rather than
62+
constructing a full ``Event`` object, avoiding expensive git and
63+
Production initialisation just to obtain a set of strings.
64+
"""
65+
names = set()
66+
for prod in ledger.events.get(event_name, {}).get("productions", []):
67+
if isinstance(prod, dict) and len(prod) == 1:
68+
names.add(next(iter(prod)))
69+
elif isinstance(prod, dict) and "name" in prod:
70+
names.add(prod["name"])
71+
return names
72+
73+
74+
def next_available_name(name, existing_names):
75+
"""Return the next available analysis name, incrementing a numeric suffix if needed.
76+
77+
If ``name`` is not already taken, it is returned unchanged. Otherwise the
78+
trailing ``-N`` suffix (if present) is stripped to find the stem, and the
79+
lowest integer >= 2 that produces a free name is appended.
80+
81+
Examples
82+
--------
83+
>>> next_available_name("bilby-IMRPhenomXPHM", {"bilby-IMRPhenomXPHM"})
84+
'bilby-IMRPhenomXPHM-2'
85+
>>> next_available_name("bilby-IMRPhenomXPHM-2", {"bilby-IMRPhenomXPHM-2"})
86+
'bilby-IMRPhenomXPHM-3'
87+
"""
88+
if name not in existing_names:
89+
return name
90+
match = re.match(r"^(.*)-(\d+)$", name)
91+
stem, n = (match.group(1), int(match.group(2))) if match else (name, 1)
92+
n += 1
93+
while f"{stem}-{n}" in existing_names:
94+
n += 1
95+
return f"{stem}-{n}"
96+
97+
98+
def apply_page(file, event=None, ledger=None, update_page=False, name=None, iterate=False):
5899
# Get ledger if not provided
59100
if ledger is None:
60101
ledger = get_ledger()
@@ -121,10 +162,10 @@ def apply_page(file, event=None, ledger=None, update_page=False):
121162
history[version]["date changed"] = datetime.now()
122163

123164
ledger.data["history"][event_obj.name] = history
124-
ledger.save()
125165
update(ledger.events[event_obj.name], event_obj.meta)
126166
ledger.events[event_obj.name]["productions"] = analyses
127167
ledger.events[event_obj.name].pop("ledger", None)
168+
ledger.save()
128169

129170
click.echo(
130171
click.style("●", fg="green") + f" Successfully updated {event_obj.name}"
@@ -169,33 +210,46 @@ def apply_page(file, event=None, ledger=None, update_page=False):
169210
prompt = "Which event should these be applied to?"
170211
event_s = str(click.prompt(prompt))
171212

213+
# Resolve name overrides before constructing the Event object.
214+
# Reading from the raw ledger dict is cheap; get_event() is expensive
215+
# (it instantiates Production objects and runs git/graph operations).
216+
if name is not None or iterate:
217+
existing_names = _raw_production_names(ledger, event_s)
172218
for expanded_doc in expanded_documents:
173-
174-
try:
175-
event_obj = ledger.get_event(event_s)[0]
176-
except KeyError as e:
177-
click.echo(
178-
click.style("●", fg="red")
179-
+ f" Could not apply a production, couldn't find the event {event}"
180-
)
181-
logger.exception(e)
182-
production = asimov.event.Production.from_dict(
183-
parameters=expanded_doc, subject=event_obj, ledger=ledger
184-
)
185-
try:
186-
ledger.add_analysis(production, event=event_obj)
187-
click.echo(
188-
click.style("●", fg="green")
189-
+ f" Successfully applied {production.name} to {event_obj.name}"
190-
)
191-
logger.info(f"Added {production.name} to {event_obj.name}")
192-
except ValueError as e:
193-
click.echo(
194-
click.style("●", fg="red")
195-
+ f" Could not apply {production.name} to {event_obj.name} as "
196-
+ "an analysis already exists with this name"
197-
)
198-
logger.exception(e)
219+
if name is not None:
220+
expanded_doc["name"] = name
221+
elif iterate:
222+
expanded_doc["name"] = next_available_name(expanded_doc["name"], existing_names)
223+
# Keep existing_names current so consecutive iterations in a
224+
# strategy expansion don't collide with each other.
225+
existing_names.add(expanded_doc["name"])
226+
227+
try:
228+
event_obj = ledger.get_event(event_s)[0]
229+
except KeyError as e:
230+
click.echo(
231+
click.style("●", fg="red")
232+
+ f" Could not apply a production, couldn't find the event {event}"
233+
)
234+
logger.exception(e)
235+
continue
236+
production = asimov.event.Production.from_dict(
237+
parameters=expanded_doc, subject=event_obj, ledger=ledger
238+
)
239+
try:
240+
ledger.add_analysis(production, event=event_obj)
241+
click.echo(
242+
click.style("●", fg="green")
243+
+ f" Successfully applied {production.name} to {event_obj.name}"
244+
)
245+
logger.info(f"Added {production.name} to {event_obj.name}")
246+
except ValueError as e:
247+
click.echo(
248+
click.style("●", fg="red")
249+
+ f" Could not apply {production.name} to {event_obj.name} as "
250+
+ "an analysis already exists with this name"
251+
)
252+
logger.exception(e)
199253

200254
elif document["kind"].lower() == "postprocessing":
201255
# Handle a project analysis
@@ -436,11 +490,24 @@ def apply_via_plugin(event, hookname, **kwargs):
436490
default=False,
437491
help="Update the project with this blueprint rather than adding a new record.",
438492
)
439-
def apply(file, event, plugin, update):
493+
@click.option(
494+
"--name",
495+
"-n",
496+
default=None,
497+
help="Override the analysis name specified in the blueprint.",
498+
)
499+
@click.option(
500+
"--iterate",
501+
"-I",
502+
is_flag=True,
503+
default=False,
504+
help="Automatically increment the analysis name suffix to avoid a name conflict.",
505+
)
506+
def apply(file, event, plugin, update, name, iterate):
440507
from asimov import setup_file_logging
441508
current_ledger = get_ledger()
442509
setup_file_logging()
443510
if plugin:
444511
apply_via_plugin(event, hookname=plugin)
445512
elif file:
446-
apply_page(file, event, ledger=current_ledger, update_page=update)
513+
apply_page(file, event, ledger=current_ledger, update_page=update, name=name, iterate=iterate)

asimov/cli/monitor.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,20 @@ def _has_pesummary_outputs(webdir: Path) -> bool:
184184
logger.warning(f"Failed to use new JobList, falling back to legacy: {e}")
185185
try:
186186
job_list = condor.CondorJobList()
187-
except condor.htcondor.HTCondorLocateError:
188-
click.echo(click.style("Could not find the scheduler", bold=True))
189-
click.echo(
190-
"You need to run asimov on a machine which has access to a"
191-
"scheduler in order to work correctly, or to specify"
192-
"the address of a valid scheduler."
193-
)
194-
sys.exit()
187+
except Exception as locate_error:
188+
# Handle both HTCondor 1 and 2 exceptions
189+
error_name = type(locate_error).__name__
190+
if "Locate" in error_name or "locate" in str(locate_error).lower():
191+
click.echo(click.style("Could not find the scheduler", bold=True))
192+
click.echo(
193+
"You need to run asimov on a machine which has access to a"
194+
"scheduler in order to work correctly, or to specify"
195+
"the address of a valid scheduler."
196+
)
197+
sys.exit()
198+
else:
199+
# Re-raise if it's not a locate error
200+
raise
195201

196202
# also check the analyses in the project analyses
197203
for analysis in ledger.project_analyses:

0 commit comments

Comments
 (0)