starbash 0.1.9__py3-none-any.whl → 0.1.15__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 (44) hide show
  1. repo/__init__.py +1 -1
  2. repo/manager.py +14 -23
  3. repo/repo.py +52 -10
  4. starbash/__init__.py +10 -3
  5. starbash/aliases.py +145 -0
  6. starbash/analytics.py +3 -2
  7. starbash/app.py +512 -473
  8. starbash/check_version.py +18 -0
  9. starbash/commands/__init__.py +2 -1
  10. starbash/commands/info.py +88 -14
  11. starbash/commands/process.py +76 -24
  12. starbash/commands/repo.py +41 -68
  13. starbash/commands/select.py +141 -142
  14. starbash/commands/user.py +88 -23
  15. starbash/database.py +219 -112
  16. starbash/defaults/starbash.toml +24 -3
  17. starbash/exception.py +21 -0
  18. starbash/main.py +29 -7
  19. starbash/paths.py +35 -5
  20. starbash/processing.py +724 -0
  21. starbash/recipes/README.md +3 -0
  22. starbash/recipes/master_bias/starbash.toml +16 -19
  23. starbash/recipes/master_dark/starbash.toml +33 -0
  24. starbash/recipes/master_flat/starbash.toml +26 -18
  25. starbash/recipes/osc.py +190 -0
  26. starbash/recipes/osc_dual_duo/starbash.toml +54 -44
  27. starbash/recipes/osc_simple/starbash.toml +82 -0
  28. starbash/recipes/osc_single_duo/starbash.toml +51 -32
  29. starbash/recipes/seestar/starbash.toml +82 -0
  30. starbash/recipes/starbash.toml +30 -9
  31. starbash/selection.py +32 -36
  32. starbash/templates/repo/master.toml +7 -3
  33. starbash/templates/repo/processed.toml +15 -0
  34. starbash/templates/userconfig.toml +9 -0
  35. starbash/toml.py +13 -13
  36. starbash/tool.py +230 -96
  37. starbash-0.1.15.dist-info/METADATA +216 -0
  38. starbash-0.1.15.dist-info/RECORD +45 -0
  39. starbash/recipes/osc_dual_duo/starbash.py +0 -151
  40. starbash-0.1.9.dist-info/METADATA +0 -145
  41. starbash-0.1.9.dist-info/RECORD +0 -37
  42. {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/WHEEL +0 -0
  43. {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/entry_points.txt +0 -0
  44. {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,18 @@
1
+ import logging
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ from update_checker import UpdateChecker
5
+
6
+
7
+ def check_version():
8
+ """Check if a newer version of starbash is available on PyPI."""
9
+ try:
10
+ checker = UpdateChecker()
11
+ current_version = version("starbash")
12
+ result = checker.check("starbash", current_version)
13
+ if result:
14
+ logging.warning(result)
15
+
16
+ except PackageNotFoundError:
17
+ # Package not installed (e.g., running from source during development)
18
+ pass
@@ -1,11 +1,12 @@
1
1
  """Shared utilities for starbash commands."""
2
2
 
3
- from datetime import datetime
4
3
  from rich.style import Style
5
4
 
6
5
  # Define reusable table styles
7
6
  TABLE_COLUMN_STYLE = Style(color="cyan")
8
7
  TABLE_VALUE_STYLE = Style(color="green")
8
+ TABLE_HEADER_STYLE = Style(color="magenta", bold=True)
9
+ SPINNER_STYLE = Style(color="magenta", bold=True)
9
10
 
10
11
 
11
12
  def format_duration(seconds: int | float) -> str:
starbash/commands/info.py CHANGED
@@ -1,15 +1,19 @@
1
1
  """Info commands for displaying system and data information."""
2
2
 
3
+ from collections import Counter
4
+ from typing import Annotated
5
+
3
6
  import typer
4
- from typing_extensions import Annotated
5
7
  from rich.table import Table
6
- from collections import Counter
7
8
 
8
9
  from starbash.app import Starbash
9
- from starbash import console
10
+ from starbash.commands import (
11
+ TABLE_COLUMN_STYLE,
12
+ TABLE_HEADER_STYLE,
13
+ TABLE_VALUE_STYLE,
14
+ format_duration,
15
+ )
10
16
  from starbash.database import Database, get_column_name
11
- from starbash.paths import get_user_config_dir, get_user_data_dir
12
- from starbash.commands import format_duration, TABLE_COLUMN_STYLE, TABLE_VALUE_STYLE
13
17
 
14
18
  app = typer.Typer()
15
19
 
@@ -27,7 +31,7 @@ def dump_column(sb: Starbash, human_name: str, column_name: str) -> None:
27
31
  sessions = sb.search_session()
28
32
 
29
33
  # Also do a complete unfiltered search so we can compare for the users
30
- allsessions = sb.db.search_session(("", []))
34
+ allsessions = sb.db.search_session([])
31
35
 
32
36
  column_name = get_column_name(column_name)
33
37
  found = [session[column_name] for session in sessions if session[column_name]]
@@ -42,16 +46,17 @@ def dump_column(sb: Starbash, human_name: str, column_name: str) -> None:
42
46
 
43
47
  # Create and display table
44
48
  table = Table(
45
- title=f"{plural(human_name)} ({len(found_counts)} / {len(all_counts)} selected)"
49
+ header_style=TABLE_HEADER_STYLE,
50
+ title=f"{plural(human_name)} ({len(found_counts)} / {len(all_counts)} selected)",
46
51
  )
47
52
  table.add_column(human_name, style=TABLE_COLUMN_STYLE, no_wrap=False)
48
- table.add_column(
49
- "# of sessions", style=TABLE_COLUMN_STYLE, no_wrap=True, justify="right"
50
- )
53
+ table.add_column("# of sessions", style=TABLE_COLUMN_STYLE, no_wrap=True, justify="right")
51
54
 
52
55
  for i, count in sorted_list:
53
56
  table.add_row(i, str(count))
54
57
 
58
+ from starbash import console
59
+
55
60
  console.print(table)
56
61
 
57
62
 
@@ -76,15 +81,86 @@ def filter():
76
81
  dump_column(sb, "Filter", Database.FILTER_KEY)
77
82
 
78
83
 
84
+ kind_arg = typer.Argument(
85
+ help="Optional image type to filter by (e.g., BIAS, DARK, FLAT, LIGHT)",
86
+ )
87
+
88
+
89
+ @app.command()
90
+ def master(
91
+ kind: Annotated[
92
+ str | None,
93
+ kind_arg,
94
+ ] = None,
95
+ ):
96
+ """List all precalculated master images (darks, biases, flats)."""
97
+ with Starbash("info.master") as sb:
98
+ from starbash import console
99
+
100
+ # Get the master repo
101
+ images = sb.get_master_images(kind)
102
+
103
+ if not images:
104
+ kind_msg = f" of type '{kind}'" if kind else ""
105
+ console.print(f"[yellow]No master images{kind_msg} found.[/yellow]")
106
+ return
107
+
108
+ # Create table to display results
109
+ title = f"Master Images ({len(images)} total)"
110
+ if kind:
111
+ title = f"Master {kind} Images ({len(images)} total)"
112
+ table = Table(title=title, header_style=TABLE_HEADER_STYLE)
113
+ table.add_column("Date", style=TABLE_COLUMN_STYLE, no_wrap=True)
114
+ table.add_column("Type", style=TABLE_COLUMN_STYLE, no_wrap=True)
115
+ table.add_column("Filename", style=TABLE_VALUE_STYLE, no_wrap=False)
116
+
117
+ # Sort by date, then by type
118
+ sorted_images = sorted(
119
+ images,
120
+ key=lambda img: (
121
+ img.get(Database.DATE_OBS_KEY) or img.get(Database.DATE_KEY) or "",
122
+ img.get(Database.IMAGETYP_KEY) or "",
123
+ ),
124
+ )
125
+
126
+ for image in sorted_images:
127
+ date = image.get(Database.DATE_OBS_KEY) or image.get(Database.DATE_KEY) or "Unknown"
128
+ # Extract just the date part (YYYY-MM-DD) if it's a full ISO timestamp
129
+ if "T" in date:
130
+ date = date.split("T")[0]
131
+
132
+ kind = image.get(Database.IMAGETYP_KEY)
133
+ if kind:
134
+ kind = sb.aliases.normalize(kind)
135
+ filename = image.get("path") or "Unknown"
136
+
137
+ table.add_row(date, kind, filename)
138
+
139
+ console.print(table)
140
+
141
+
142
+ @app.command(hidden=True)
143
+ def masters(
144
+ kind: Annotated[
145
+ str | None,
146
+ kind_arg,
147
+ ] = None,
148
+ ):
149
+ """Alias for 'info master' command."""
150
+ master(kind)
151
+
152
+
79
153
  @app.callback(invoke_without_command=True)
80
154
  def main_callback(ctx: typer.Context):
81
155
  """Show user preferences location and other app info.
82
156
 
83
157
  This is the default command when no subcommand is specified.
84
158
  """
159
+ from starbash import console
160
+
85
161
  if ctx.invoked_subcommand is None:
86
162
  with Starbash("info") as sb:
87
- table = Table(title="Starbash Information")
163
+ table = Table(title="Starbash Information", header_style=TABLE_HEADER_STYLE)
88
164
  table.add_column("Setting", style=TABLE_COLUMN_STYLE, no_wrap=True)
89
165
  table.add_column("Value", style=TABLE_VALUE_STYLE)
90
166
 
@@ -106,9 +182,7 @@ def main_callback(ctx: typer.Context):
106
182
  table.add_row("User Repositories", str(len(sb.repo_manager.regular_repos)))
107
183
 
108
184
  # Show database stats
109
- table.add_row(
110
- "Sessions Indexed", str(sb.db.len_table(Database.SESSIONS_TABLE))
111
- )
185
+ table.add_row("Sessions Indexed", str(sb.db.len_table(Database.SESSIONS_TABLE)))
112
186
 
113
187
  table.add_row("Images Indexed", str(sb.db.len_table(Database.IMAGES_TABLE)))
114
188
 
@@ -1,13 +1,19 @@
1
1
  """Processing commands for automated image processing workflows."""
2
2
 
3
- import typer
4
3
  from pathlib import Path
5
- from typing_extensions import Annotated
4
+ from typing import Annotated
5
+
6
+ import rich
7
+ import typer
6
8
 
7
9
  from starbash.app import Starbash, copy_images_to_dir
8
- from starbash import console
10
+ from starbash.commands.__init__ import (
11
+ TABLE_COLUMN_STYLE,
12
+ TABLE_HEADER_STYLE,
13
+ )
9
14
  from starbash.commands.select import selection_by_number
10
15
  from starbash.database import SessionRow
16
+ from starbash.processing import Processing, ProcessingResult
11
17
 
12
18
  app = typer.Typer()
13
19
 
@@ -20,9 +26,7 @@ def siril(
20
26
  ],
21
27
  destdir: Annotated[
22
28
  str,
23
- typer.Argument(
24
- help="Destination directory for Siril directory tree and processing"
25
- ),
29
+ typer.Argument(help="Destination directory for Siril directory tree and processing"),
26
30
  ],
27
31
  run: Annotated[
28
32
  bool,
@@ -42,6 +46,8 @@ def siril(
42
46
  structure loaded and ready for processing.
43
47
  """
44
48
  with Starbash("process.siril") as sb:
49
+ from starbash import console
50
+
45
51
  console.print(
46
52
  f"[yellow]Processing session {session_num} for Siril in {destdir}...[/yellow]"
47
53
  )
@@ -67,9 +73,9 @@ def siril(
67
73
 
68
74
  extras = [
69
75
  # FIXME search for BIAS/DARK/FLAT etc... using multiple canonical names
70
- ("BIAS", "biases"),
71
- ("DARK", "darks"),
72
- ("FLAT", "flats"),
76
+ ("bias", "biases"),
77
+ ("dark", "darks"),
78
+ ("flat", "flats"),
73
79
  ]
74
80
  for typ, subdir in extras:
75
81
  candidates = sb.guess_sessions(session, typ)
@@ -85,6 +91,48 @@ def siril(
85
91
  # Also FIXME, check for the existence of such a file
86
92
 
87
93
 
94
+ def print_results(
95
+ title: str, results: list[ProcessingResult], console: rich.console.Console
96
+ ) -> None:
97
+ """Print processing results in a formatted table.
98
+
99
+ Args:
100
+ title: Title to display above the table
101
+ results: List of ProcessingResult objects to display
102
+ console: Rich console instance for output
103
+ """
104
+ from rich.table import Table
105
+
106
+ if not results:
107
+ console.print(f"[yellow]{title}: No results to display[/yellow]")
108
+ return
109
+
110
+ table = Table(title=title, show_header=True, header_style=TABLE_HEADER_STYLE)
111
+ table.add_column("Target", style=TABLE_COLUMN_STYLE, no_wrap=True)
112
+ table.add_column("Sessions", justify="right", style=TABLE_COLUMN_STYLE)
113
+ table.add_column("Status", justify="center", style=TABLE_COLUMN_STYLE)
114
+ table.add_column("Notes", style=TABLE_COLUMN_STYLE)
115
+
116
+ for result in results:
117
+ # Format status with color
118
+ if result.success is True:
119
+ status = "[green]✓ Success[/green]"
120
+ elif result.success is False:
121
+ status = "[red]✗ Failed[/red]"
122
+ else:
123
+ status = "[yellow]⊘ Skipped[/yellow]"
124
+
125
+ # Format session count
126
+ session_count = str(len(result.sessions))
127
+
128
+ # Format notes (truncate if too long)
129
+ notes = result.notes or ""
130
+
131
+ table.add_row(result.target, session_count, status, notes)
132
+
133
+ console.print(table)
134
+
135
+
88
136
  @app.command()
89
137
  def auto(
90
138
  session_num: Annotated[
@@ -109,15 +157,17 @@ def auto(
109
157
  The output will be saved according to the configured recipes.
110
158
  """
111
159
  with Starbash("process.auto") as sb:
112
- if session_num is not None:
113
- console.print(f"[yellow]Auto-processing session {session_num}...[/yellow]")
114
- else:
115
- console.print("[yellow]Auto-processing all selected sessions...[/yellow]")
160
+ with Processing(sb) as proc:
161
+ from starbash import console
116
162
 
117
- console.print(
118
- "[red]Still in development - see https://github.com/geeksville/starbash[/red]"
119
- )
120
- sb.run_all_stages()
163
+ if session_num is not None:
164
+ console.print(f"[yellow]Auto-processing session {session_num}...[/yellow]")
165
+ else:
166
+ console.print("[yellow]Auto-processing all selected sessions...[/yellow]")
167
+
168
+ results = proc.run_all_stages()
169
+
170
+ print_results("Autoprocessed", results, console)
121
171
 
122
172
 
123
173
  @app.command()
@@ -132,13 +182,13 @@ def masters():
132
182
  and will be automatically used for future processing operations.
133
183
  """
134
184
  with Starbash("process.masters") as sb:
135
- console.print(
136
- "[yellow]Generating master frames from current selection...[/yellow]"
137
- )
138
- console.print(
139
- "[red]Still in development - see https://github.com/geeksville/starbash[/red]"
140
- )
141
- sb.run_master_stages()
185
+ with Processing(sb) as proc:
186
+ from starbash import console
187
+
188
+ console.print("[yellow]Generating master frames from current selection...[/yellow]")
189
+ results = proc.run_master_stages()
190
+
191
+ print_results("Generated masters", results, console)
142
192
 
143
193
 
144
194
  @app.callback(invoke_without_command=True)
@@ -149,6 +199,8 @@ def main_callback(ctx: typer.Context):
149
199
  post-processing of astrophotography sessions.
150
200
  """
151
201
  if ctx.invoked_subcommand is None:
202
+ from starbash import console
203
+
152
204
  # No command provided, show help
153
205
  console.print(ctx.get_help())
154
206
  raise typer.Exit()
starbash/commands/repo.py CHANGED
@@ -1,13 +1,14 @@
1
- import typer
2
- from typing_extensions import Annotated
3
- from pathlib import Path
4
1
  import logging
2
+ from textwrap import dedent
3
+ from typing import Annotated
4
+
5
+ import typer
5
6
 
6
7
  import starbash
7
- from repo import repo_suffix, Repo
8
+ from repo import Repo
9
+ from starbash import console
8
10
  from starbash.app import Starbash
9
- from starbash import console, log_filter_level
10
- from starbash.toml import toml_from_template
11
+ from starbash.paths import get_user_documents_dir
11
12
 
12
13
  app = typer.Typer(invoke_without_command=True)
13
14
 
@@ -22,9 +23,7 @@ def repo_enumeration(sb: Starbash):
22
23
 
23
24
  def complete_repo_by_num(incomplete: str):
24
25
  # We need to use stderr_logging to prevent confusing the bash completion parser
25
- starbash.log_filter_level = (
26
- logging.ERROR
27
- ) # avoid showing output while doing completion
26
+ starbash.log_filter_level = logging.ERROR # avoid showing output while doing completion
28
27
  with Starbash("repo.complete.num", stderr_logging=True) as sb:
29
28
  for num, repo in repo_enumeration(sb).items():
30
29
  if str(num).startswith(incomplete):
@@ -33,9 +32,7 @@ def complete_repo_by_num(incomplete: str):
33
32
 
34
33
  def complete_repo_by_url(incomplete: str):
35
34
  # We need to use stderr_logging to prevent confusing the bash completion parser
36
- starbash.log_filter_level = (
37
- logging.ERROR
38
- ) # avoid showing output while doing completion
35
+ starbash.log_filter_level = logging.ERROR # avoid showing output while doing completion
39
36
  with Starbash("repo.complete.url", stderr_logging=True) as sb:
40
37
  repos = sb.repo_manager.regular_repos
41
38
 
@@ -45,28 +42,23 @@ def complete_repo_by_url(incomplete: str):
45
42
 
46
43
 
47
44
  @app.command()
48
- def list(
49
- verbose: bool = typer.Option(
50
- False, "--verbose", "-v", help="Show all repos including system repos"
51
- ),
52
- ):
45
+ def list():
53
46
  """
54
47
  lists all repositories.
55
- Use --verbose to show all repos including system/recipe repos.
56
48
  """
57
49
  with Starbash("repo.list") as sb:
58
- repos = sb.repo_manager.repos if verbose else sb.repo_manager.regular_repos
50
+ repos = sb.repo_manager.repos if starbash.verbose_output else sb.repo_manager.regular_repos
59
51
  for i, repo in enumerate(repos):
60
52
  kind = repo.kind("input")
61
53
  # for unknown repos (probably because we haven't written a starbash.toml file to the root yet),
62
54
  # we call them "input" because users will be less confused by that
63
55
 
64
- if verbose:
56
+ if starbash.verbose_output:
65
57
  # No numbers for verbose mode (system repos can't be removed)
66
- console.print(f"{ repo.url } (kind={ kind })")
58
+ console.print(f"{repo.url} (kind={kind})")
67
59
  else:
68
60
  # Show numbers for user repos (can be removed later)
69
- console.print(f"{ i + 1:2}: { repo.url } (kind={ kind })")
61
+ console.print(f"{i + 1:2}: {repo.url} (kind={kind})")
70
62
 
71
63
 
72
64
  @app.callback()
@@ -87,10 +79,15 @@ def main(
87
79
 
88
80
  @app.command()
89
81
  def add(
90
- path: str,
82
+ path: Annotated[str | None, typer.Argument(help="Path to the respository")] = None,
91
83
  master: bool = typer.Option(
92
84
  False, "--master", help="Mark this new repository for master files."
93
85
  ),
86
+ processed: bool = typer.Option(
87
+ False,
88
+ "--processed",
89
+ help="Mark this new repository for processed output files.",
90
+ ),
94
91
  ):
95
92
  """
96
93
  Add a repository. path is either a local path or a remote URL.
@@ -98,43 +95,26 @@ def add(
98
95
  repo_type = None
99
96
  if master:
100
97
  repo_type = "master"
101
- with Starbash("repo.add") as sb:
102
- p = Path(path)
98
+ elif processed:
99
+ repo_type = "processed"
103
100
 
104
- repo_toml = p / repo_suffix # the starbash.toml file at the root of the repo
105
- if repo_toml.exists():
106
- logging.warning("Using existing repository config file: %s", repo_toml)
101
+ if path is None:
102
+ if repo_type is not None:
103
+ # if we know the repo type we can auto create a default path
104
+ path = str(get_user_documents_dir() / "repos" / repo_type)
107
105
  else:
108
- if repo_type:
109
- console.print(f"Creating {repo_type} repository: {p}")
110
- p.mkdir(parents=True, exist_ok=True)
111
-
112
- toml_from_template(
113
- f"repo/{repo_type}",
114
- p / repo_suffix,
115
- overrides={
116
- "REPO_TYPE": repo_type,
117
- "REPO_PATH": str(p),
118
- "DEFAULT_RELATIVE": "{instrument}/{date}/{imagetyp}/master_{imagetyp}.fits",
119
- },
120
- )
121
- else:
122
- # No type specified, therefore (for now) assume we are just using this as an input
123
- # repo (and it must exist)
124
- if not p.exists():
125
- console.print(f"[red]Error: Repo path does not exist: {p}[/red]")
126
- raise typer.Exit(code=1)
127
-
128
- console.print(f"Adding repository: {p}")
106
+ console.print("[red]Error: path is required for input repositories[/red]")
107
+ raise typer.Exit(1)
129
108
 
130
- repo = sb.user_repo.add_repo_ref(p)
131
- if repo:
132
- sb.reindex_repo(repo)
109
+ with Starbash("repo.add") as sb:
110
+ if repo_type and sb.repo_manager.get_repo_by_kind(repo_type):
111
+ console.print(
112
+ dedent(f"""
113
+ [red]Error[/red]: A repository for '{repo_type}' files already exists. If you'd like to replace it, use 'sb repo remove <url|number>' first.""")
114
+ )
115
+ raise typer.Exit(1)
133
116
 
134
- # we don't yet always write default config files at roots of repos, but it would be easy to add here
135
- # r.write_config()
136
- sb.user_repo.write_config()
137
- # FIXME, we also need to index the newly added repo!!!
117
+ sb.add_local_repo(path, repo_type=repo_type)
138
118
 
139
119
 
140
120
  def repo_url_to_repo(sb: Starbash, repo_url: str | None) -> Repo | None:
@@ -173,9 +153,7 @@ def repo_url_to_repo(sb: Starbash, repo_url: str | None) -> Repo | None:
173
153
  def remove(
174
154
  reponum: Annotated[
175
155
  str,
176
- typer.Argument(
177
- help="Repository number or URL", autocompletion=complete_repo_by_url
178
- ),
156
+ typer.Argument(help="Repository number or URL", autocompletion=complete_repo_by_url),
179
157
  ],
180
158
  ):
181
159
  """
@@ -186,7 +164,7 @@ def remove(
186
164
  # Get the repo to remove
187
165
  repo_to_remove = repo_url_to_repo(sb, reponum)
188
166
  if repo_to_remove is None:
189
- console.print(f"[red]Error: You must specify a repository[/red]")
167
+ console.print("[red]Error: You must specify a repository[/red]")
190
168
  raise typer.Exit(code=1)
191
169
  repo_url = repo_to_remove.url
192
170
 
@@ -204,9 +182,6 @@ def reindex(
204
182
  autocompletion=complete_repo_by_url,
205
183
  ),
206
184
  ] = None,
207
- force: bool = typer.Option(
208
- default=False, help="Reread FITS headers, even if they are already indexed."
209
- ),
210
185
  ):
211
186
  """
212
187
  Reindex a repository by number.
@@ -217,14 +192,12 @@ def reindex(
217
192
  repo_to_reindex = repo_url_to_repo(sb, repo_url)
218
193
 
219
194
  if repo_to_reindex is None:
220
- sb.reindex_repos(force=force)
195
+ sb.reindex_repos()
221
196
  else:
222
197
  # Get the repo to reindex
223
198
  console.print(f"Reindexing repository: {repo_to_reindex.url}")
224
- sb.reindex_repo(repo_to_reindex, force=force)
225
- console.print(
226
- f"[green]Successfully reindexed repository {repo_to_reindex}[/green]"
227
- )
199
+ sb.reindex_repo(repo_to_reindex)
200
+ console.print(f"[green]Successfully reindexed repository {repo_to_reindex}[/green]")
228
201
 
229
202
 
230
203
  if __name__ == "__main__":