backlogops-cli 0.2__py3-none-any.whl → 0.4__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.
@@ -20,11 +20,39 @@ from backlogops_cli._migrate_warn import (
20
20
  CliMigrateWarnHook, CliPresetMigrateWarnHook)
21
21
  from backlogops_cli.bloc_version_reporter import BloCliVersionReporter
22
22
  from backlogops import (
23
- BacklogOpsConfig, BacklogReleases, FileExistsCb, FormatRules, Levels,
24
- ReleaseChanges, ReleaseDateChanges, allow_overwrite,
25
- format_content_changes, format_date_changes, get_backlog_ops_config,
26
- read_backlog_releases, resolve_input_config, resolve_output_config,
27
- write_backlog_releases, write_content_changes, write_date_changes)
23
+ BacklogOpsConfig, BacklogReleases, FileExistsCb, FormatRules,
24
+ JiraRankAnchor, Levels, ReleaseChanges, ReleaseDateChanges,
25
+ allow_overwrite, format_content_changes, format_date_changes,
26
+ get_backlog_ops_config, read_backlog_releases, resolve_input_config,
27
+ resolve_output_config, write_backlog_releases, write_content_changes,
28
+ write_date_changes)
29
+
30
+ RANK_ANCHOR_CHOICES = {
31
+ 'backlog-top': JiraRankAnchor.BACKLOG_TOP,
32
+ 'backlog-bottom': JiraRankAnchor.BACKLOG_BOTTOM,
33
+ 'first-key': JiraRankAnchor.FIRST_KEY,
34
+ 'last-key': JiraRankAnchor.LAST_KEY}
35
+ """Command-line anchor names mapped to :class:`JiraRankAnchor` members."""
36
+
37
+
38
+ def rank_anchor(value: Optional[str]) -> Optional[JiraRankAnchor]:
39
+ """Return the anchor for a CLI anchor name, or None for a None value."""
40
+ return None if value is None else RANK_ANCHOR_CHOICES[value]
41
+
42
+
43
+ def add_rank_arg(parser: argparse.ArgumentParser) -> None:
44
+ """Add the optional ``--rank`` flag choosing a Jira rank anchor.
45
+
46
+ Shared by the add and update commands, where ranking is opt-in: without
47
+ ``--rank`` the Jira rank order is left unchanged.
48
+ """
49
+ parser.add_argument('--rank', dest='rank', default=None,
50
+ choices=sorted(RANK_ANCHOR_CHOICES),
51
+ help='Also set the Jira rank order to match the '
52
+ 'supplied backlog, placing the items at this anchor: '
53
+ 'backlog-top, backlog-bottom, first-key (keep the '
54
+ 'first item fixed) or last-key (keep the last item '
55
+ 'fixed). Omit to leave the Jira rank order unchanged.')
28
56
 
29
57
 
30
58
  def overwrite_callback(force: bool, in_stream: Optional[TextIO] = None,
@@ -255,11 +283,14 @@ def _write_output(parsed: argparse.Namespace,
255
283
 
256
284
  def run_write(parsed: argparse.Namespace,
257
285
  data_source: Callable[[Optional[BacklogOpsConfig]],
258
- BacklogReleases]) -> int:
286
+ BacklogReleases],
287
+ require_config: bool = False) -> int:
259
288
  """Build the data, write it to the output file, and report the result.
260
289
 
261
- The configuration is resolved once from ``-c`` or by discovery, falling
262
- back to the built-in defaults when none is found.
290
+ The configuration is resolved once from ``-c`` or by discovery. It
291
+ falls back to the built-in defaults when none is found, unless
292
+ ``require_config`` is True, in which case a missing configuration is
293
+ reported as an error.
263
294
 
264
295
  Args:
265
296
  parsed: Parsed command line arguments holding the output options
@@ -269,12 +300,15 @@ def run_write(parsed: argparse.Namespace,
269
300
  None) and returns the backlog and releases to write. It is
270
301
  called inside the error handling so that reading failures are
271
302
  reported like writing failures.
303
+ require_config: When True a missing configuration is reported as an
304
+ error instead of falling back to the built-in defaults.
272
305
 
273
306
  Returns:
274
307
  ``0`` on success, ``1`` when the data cannot be built or written.
275
308
  """
276
309
  try:
277
- config = optional_config(parsed)
310
+ config = (required_config(parsed) if require_config
311
+ else optional_config(parsed))
278
312
  _write_output(parsed, config, data_source(config))
279
313
  except (ValueError, TypeError, KeyError, OSError) as error:
280
314
  print(f'Could not write {parsed.output}: {error}', file=sys.stderr)
@@ -3,10 +3,11 @@
3
3
 
4
4
  Both wizard commands write a JSON configuration file built interactively
5
5
  through a ``WizardUiBridge``. They share the same command line shape (an
6
- output file, a switch forcing the plain console interface, and a force
7
- flag), the same overwrite check, and the same run-write-report flow. That
8
- shared logic lives here; the leading underscore in the module name keeps
9
- it out of the command listing.
6
+ output file, an optional input file whose contents pre-fill the wizard, a
7
+ switch forcing the plain console interface, and a force flag), the same
8
+ overwrite check, the same read-run-write-report flow, and the same
9
+ crash-safe write. That shared logic lives here; the leading underscore in
10
+ the module name keeps it out of the command listing.
10
11
  """
11
12
  # PYTHON_ARGCOMPLETE_OK
12
13
 
@@ -17,21 +18,32 @@ import argparse
17
18
  import sys
18
19
  from collections.abc import Callable
19
20
  from pathlib import Path
21
+ from typing import Optional, TypeVar
20
22
  from config_as_json import Config
21
23
  from config_as_json.file_extension import fix_file_extension
22
24
  from tableio_cfg_json import WizardUiBridge, WizardUiBridgeConsole, \
23
25
  make_text_ui_bridge
26
+ from backlogops import safe_write_config
24
27
  from backlogops_cli._command_io import add_force_arg, overwrite_callback
25
28
 
26
29
  CONFIG_EXTENSION = '.cfg'
30
+ _ConfigT = TypeVar('_ConfigT', bound=Config)
27
31
 
28
32
 
29
33
  def build_wizard_parser(description: str) -> argparse.ArgumentParser:
30
- """Build a wizard parser with output, no-textual and force options."""
34
+ """Build a wizard parser with output, input, no-textual and force."""
31
35
  parser = argparse.ArgumentParser(description=description)
32
36
  parser.add_argument('-o', '--output', dest='output', required=True,
33
37
  help='Configuration file to write; the '
34
38
  f'{CONFIG_EXTENSION} extension is added if missing.')
39
+ parser.add_argument('-i', '--input', dest='input',
40
+ help='Existing configuration file whose contents '
41
+ 'pre-fill the wizard as the starting answers, so you '
42
+ 'edit them instead of starting empty. Use the same '
43
+ 'path as -o to edit that file in place; you are then '
44
+ f'asked to confirm overwriting it. The '
45
+ f'{CONFIG_EXTENSION} extension is assumed when the '
46
+ 'file is not found as named.')
35
47
  parser.add_argument('--no-textual', dest='no_textual', action='store_true',
36
48
  help='Force the plain console interface instead of '
37
49
  'the Textual full-screen interface.')
@@ -61,30 +73,52 @@ def _make_bridge(no_textual: bool) -> WizardUiBridge:
61
73
  return make_text_ui_bridge(sys.stdout, sys.stdin, sys.stderr)
62
74
 
63
75
 
76
+ def _read_default(input_file: Optional[str],
77
+ reader: Callable[[str], _ConfigT]) -> Optional[_ConfigT]:
78
+ """Read the pre-fill file, or return None when none is requested.
79
+
80
+ The ``.cfg`` extension is assumed when the named file is not found as
81
+ given, matching how the output filename is completed.
82
+ """
83
+ if input_file is None:
84
+ return None
85
+ path = fix_file_extension(input_file, CONFIG_EXTENSION, for_reading=True)
86
+ if not Path(path).is_file():
87
+ raise ValueError(f'File to read initial values from not found: {path}')
88
+ return reader(path)
89
+
90
+
64
91
  def run_wizard_to_file(parsed: argparse.Namespace,
65
- wizard: Callable[[WizardUiBridge], Config],
66
- label: str) -> int:
92
+ wizard: Callable[..., _ConfigT],
93
+ reader: Callable[[str], _ConfigT], label: str) -> int:
67
94
  """Run a wizard, write its configuration to the output file, report.
68
95
 
69
- The output filename receives the ``.cfg`` extension when it is not
70
- already present.
96
+ When ``parsed.input`` names an existing file it is read first and used
97
+ to pre-fill the wizard, so the user edits those values instead of
98
+ starting from scratch. The output filename receives the ``.cfg``
99
+ extension when it is not already present, an existing output file is
100
+ only overwritten after confirmation (or with ``--force``), and the
101
+ result is written crash-safely through a ``.in_progress`` sibling file.
71
102
 
72
103
  Args:
73
- parsed: Parsed arguments holding ``output``, ``force`` and
74
- ``no_textual``.
75
- wizard: Wizard called with the chosen UI bridge; it returns a
76
- configuration object that knows how to write itself.
104
+ parsed: Parsed arguments holding ``output``, ``input``, ``force``
105
+ and ``no_textual``.
106
+ wizard: Wizard called with the chosen UI bridge and the pre-fill
107
+ default; it returns a configuration object that knows how to
108
+ write itself.
109
+ reader: Reads the ``input`` file into a default for the wizard.
77
110
  label: Human-readable name of what was written, for the message.
78
111
 
79
112
  Returns:
80
113
  ``0`` on success, ``1`` when the wizard is abandoned or the
81
- configuration is rejected or cannot be written.
114
+ configuration is rejected or cannot be read or written.
82
115
  """
83
116
  output = fix_file_extension(parsed.output, CONFIG_EXTENSION)
84
117
  try:
118
+ default = _read_default(parsed.input, reader)
85
119
  _check_overwrite(output, parsed.force)
86
- config = wizard(_make_bridge(parsed.no_textual))
87
- config.write(to_json_filename=output, stderr_file=sys.stderr)
120
+ config = wizard(_make_bridge(parsed.no_textual), default=default)
121
+ safe_write_config(config, output, sys.stderr)
88
122
  except (ValueError, TypeError, KeyError, EOFError, OSError) as error:
89
123
  print(f'Could not create the configuration: {error}', file=sys.stderr)
90
124
  return 1
@@ -0,0 +1,144 @@
1
+ #! /usr/local/bin/python3
2
+ """Add releases to Jira from an input file, creating one version per name.
3
+
4
+ The command reads a backlog and its releases from the input file, then
5
+ adds the releases to Jira using a named preset of the backlog-ops
6
+ configuration. By default it stops with an error when a release name
7
+ already exists in Jira; ``--skip-existing`` skips those releases instead.
8
+
9
+ The added releases and the releases already in Jira are printed to stdout
10
+ as two labelled lists, unless ``-q``/``--quiet`` is given. Each list is
11
+ also written, together with the unchanged input backlog, to a file when
12
+ ``--added-file`` or ``--existing-file`` names one; without a file name the
13
+ list is not written. An encrypted Jira token is unlocked by a pass phrase
14
+ asked on the terminal only when it is needed.
15
+ """
16
+
17
+ # PYTHON_ARGCOMPLETE_OK
18
+ # Copyright (c) 2026, Tom Björkholm
19
+ # MIT License
20
+
21
+ import argparse
22
+ import sys
23
+ from getpass import getpass
24
+ from typing import Optional
25
+ from backlogops import (
26
+ AddedReleasesToJira, BacklogOpsConfig, BacklogReleases, FormatRules,
27
+ JiraConnections, OnExistingKey, ReleaseExistsError, add_releases_to_jira,
28
+ format_release_result, resolve_output_config, write_backlog_releases)
29
+ from backlogops_cli._command_io import (
30
+ add_config_arg, add_input_args, overwrite_callback, parsed_args,
31
+ read_input, required_config)
32
+ from backlogops_cli._migrate_warn import CliPresetMigrateWarnHook
33
+
34
+ DESCRIPTION = 'Add releases to Jira, creating a new version per release'
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ """Build the command line parser for the add-releases command."""
39
+ parser = argparse.ArgumentParser(description=DESCRIPTION)
40
+ add_input_args(parser)
41
+ add_config_arg(parser)
42
+ parser.add_argument('-p', '--preset', dest='preset', required=True,
43
+ help='Name of the Jira preset in the configuration.')
44
+ parser.add_argument('--skip-existing', dest='skip_existing',
45
+ action='store_true',
46
+ help='Skip releases whose name already exists in Jira '
47
+ '(the default stops with an error instead).')
48
+ parser.add_argument('--added-file', dest='added_file', metavar='FILE',
49
+ help='Write the added releases, with the input '
50
+ 'backlog, to this file. Omit to not write it.')
51
+ parser.add_argument('--existing-file', dest='existing_file',
52
+ metavar='FILE',
53
+ help='Write the releases already in Jira, with the '
54
+ 'input backlog, to this file. Omit to not write it.')
55
+ parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
56
+ help='Do not print the added and already-present '
57
+ 'lists to stdout.')
58
+ parser.add_argument('-f', '--force', dest='force', action='store_true',
59
+ help='Overwrite existing output files without '
60
+ 'asking.')
61
+ return parser
62
+
63
+
64
+ def _passphrase() -> str:
65
+ """Ask for the Jira token pass phrase on the terminal."""
66
+ return getpass('Jira API token pass phrase: ')
67
+
68
+
69
+ def _add(parsed: argparse.Namespace, config: BacklogOpsConfig,
70
+ data: BacklogReleases) -> AddedReleasesToJira:
71
+ """Add the input releases to Jira using the named preset."""
72
+ print(f"Adding releases to Jira using preset '{parsed.preset}'...",
73
+ file=sys.stderr)
74
+ connections = JiraConnections(config.get_jira_config(), _passphrase)
75
+ mode = (OnExistingKey.SKIP if parsed.skip_existing
76
+ else OnExistingKey.RAISE)
77
+ result = add_releases_to_jira(connections, parsed.preset, data.releases,
78
+ on_existing_key=mode)
79
+ print(f'Added {len(result.stored)} releases to Jira; '
80
+ f'{len(result.already_present)} already present; '
81
+ f'{len(result.failed)} failed.', file=sys.stderr)
82
+ return result
83
+
84
+
85
+ def _write_file(config: BacklogOpsConfig, path: str, data: BacklogReleases,
86
+ force: bool) -> None:
87
+ """Write one backlog-and-releases result to a file."""
88
+ out_config = resolve_output_config(None, data_file=path,
89
+ presets=config.output_configs,
90
+ auto_ch_hook=CliPresetMigrateWarnHook())
91
+ write_backlog_releases(data, path, out_config, FormatRules(),
92
+ levels=config.get_levels(),
93
+ file_exists_callback=overwrite_callback(force))
94
+ print(f'Wrote {path}')
95
+
96
+
97
+ def _write_result_files(parsed: argparse.Namespace, config: BacklogOpsConfig,
98
+ data: BacklogReleases,
99
+ result: AddedReleasesToJira) -> None:
100
+ """Write the added and already-present releases to any named files."""
101
+ backlog = list(data.backlog)
102
+ if parsed.added_file is not None:
103
+ added = BacklogReleases(backlog=backlog, releases=result.stored)
104
+ _write_file(config, parsed.added_file, added, parsed.force)
105
+ if parsed.existing_file is not None:
106
+ present = BacklogReleases(backlog=backlog,
107
+ releases=result.already_present)
108
+ _write_file(config, parsed.existing_file, present, parsed.force)
109
+
110
+
111
+ def _run(parsed: argparse.Namespace) -> int:
112
+ """Read the input, add the releases, write files and print the lists."""
113
+ try:
114
+ config = required_config(parsed)
115
+ data = read_input(parsed, config)
116
+ result = _add(parsed, config, data)
117
+ _write_result_files(parsed, config, data, result)
118
+ except ReleaseExistsError:
119
+ print('Nothing added to Jira.', file=sys.stderr)
120
+ return 1
121
+ except (ValueError, TypeError, KeyError, OSError) as error:
122
+ print(f'Could not add releases to Jira: {error}', file=sys.stderr)
123
+ return 1
124
+ if not parsed.quiet:
125
+ print(format_release_result(result))
126
+ return 0
127
+
128
+
129
+ def main(args: Optional[list[str]] = None) -> int:
130
+ """Add releases to Jira and report the added and present releases.
131
+
132
+ Args:
133
+ args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
134
+
135
+ Returns:
136
+ ``0`` on success, ``1`` when the releases cannot be added or a name
137
+ already exists in Jira without ``--skip-existing``.
138
+ """
139
+ parsed = parsed_args(build_parser(), args)
140
+ return _run(parsed)
141
+
142
+
143
+ if __name__ == '__main__': # pragma: no cover
144
+ sys.exit(main())
@@ -0,0 +1,151 @@
1
+ #! /usr/local/bin/python3
2
+ """Add a backlog to Jira from an input file, creating one issue per item.
3
+
4
+ The command reads a backlog (or a backlog and its releases) from the input
5
+ file, then adds the backlog items to Jira using a named preset of the
6
+ backlog-ops configuration. By default it stops with an error when an
7
+ item's key already exists in Jira; ``--skip-existing`` skips those items
8
+ instead. With ``--rank`` the items are also ranked in Jira to match the
9
+ supplied backlog order, at the chosen anchor.
10
+
11
+ The added items (carrying their new Jira keys) and the items already in
12
+ Jira are printed to stdout as two labelled lists, unless ``-q``/``--quiet``
13
+ is given. Each list is also written to a file when ``--added-file`` or
14
+ ``--existing-file`` names one; without a file name the list is not written.
15
+ An encrypted Jira token is unlocked by a pass phrase asked on the terminal
16
+ only when it is needed.
17
+ """
18
+
19
+ # PYTHON_ARGCOMPLETE_OK
20
+ # Copyright (c) 2026, Tom Björkholm
21
+ # MIT License
22
+
23
+ import argparse
24
+ import sys
25
+ from getpass import getpass
26
+ from typing import Optional
27
+ from backlogops import (
28
+ AddedToJira, BacklogOpsConfig, BacklogReleases, ExistsInJiraError,
29
+ FormatRules, JiraConnections, OnExistingKey, add_backlog_to_jira,
30
+ format_add_result, resolve_output_config, write_backlog_releases)
31
+ from backlogops_cli._command_io import (
32
+ add_config_arg, add_input_args, add_rank_arg, overwrite_callback,
33
+ parsed_args, rank_anchor, read_input, required_config)
34
+ from backlogops_cli._migrate_warn import CliPresetMigrateWarnHook
35
+
36
+ DESCRIPTION = 'Add a backlog to Jira, creating a new issue per item'
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ """Build the command line parser for the add-to-Jira command."""
41
+ parser = argparse.ArgumentParser(description=DESCRIPTION)
42
+ add_input_args(parser)
43
+ add_config_arg(parser)
44
+ parser.add_argument('-p', '--preset', dest='preset', required=True,
45
+ help='Name of the Jira preset in the configuration.')
46
+ parser.add_argument('--skip-existing', dest='skip_existing',
47
+ action='store_true',
48
+ help='Skip items whose key already exists in Jira '
49
+ '(the default stops with an error instead).')
50
+ add_rank_arg(parser)
51
+ parser.add_argument('--added-file', dest='added_file', metavar='FILE',
52
+ help='Write the added items, with their new Jira '
53
+ 'keys, to this file. Omit to not write it.')
54
+ parser.add_argument('--existing-file', dest='existing_file',
55
+ metavar='FILE',
56
+ help='Write the items already in Jira to this file. '
57
+ 'Omit to not write it.')
58
+ parser.add_argument('-q', '--quiet', dest='quiet', action='store_true',
59
+ help='Do not print the added and already-present '
60
+ 'lists to stdout.')
61
+ parser.add_argument('-f', '--force', dest='force', action='store_true',
62
+ help='Overwrite existing output files without '
63
+ 'asking.')
64
+ return parser
65
+
66
+
67
+ def _passphrase() -> str:
68
+ """Ask for the Jira token pass phrase on the terminal."""
69
+ return getpass('Jira API token pass phrase: ')
70
+
71
+
72
+ def _add(parsed: argparse.Namespace, config: BacklogOpsConfig,
73
+ data: BacklogReleases) -> AddedToJira:
74
+ """Add the input backlog to Jira using the named write preset."""
75
+ print(f"Adding backlog to Jira using preset '{parsed.preset}'...",
76
+ file=sys.stderr)
77
+ connections = JiraConnections(config.get_jira_config(), _passphrase)
78
+ mode = (OnExistingKey.SKIP if parsed.skip_existing
79
+ else OnExistingKey.RAISE)
80
+ result = add_backlog_to_jira(connections, parsed.preset, data.backlog,
81
+ on_existing_key=mode,
82
+ rank_anchor=rank_anchor(parsed.rank),
83
+ levels=config.get_levels(),
84
+ status_map=config.get_status_input_map())
85
+ print(f'Added {len(result.stored)} items to Jira; '
86
+ f'{len(result.already_present)} already present; '
87
+ f'{len(result.failed)} failed; '
88
+ f'{len(result.failed_links)} links not written.', file=sys.stderr)
89
+ return result
90
+
91
+
92
+ def _write_backlog_file(config: BacklogOpsConfig, path: str,
93
+ data: BacklogReleases, force: bool) -> None:
94
+ """Write one returned backlog and the input releases to a file."""
95
+ out_config = resolve_output_config(None, data_file=path,
96
+ presets=config.output_configs,
97
+ auto_ch_hook=CliPresetMigrateWarnHook())
98
+ write_backlog_releases(data, path, out_config, FormatRules(),
99
+ levels=config.get_levels(),
100
+ file_exists_callback=overwrite_callback(force))
101
+ print(f'Wrote {path}')
102
+
103
+
104
+ def _write_result_files(parsed: argparse.Namespace, config: BacklogOpsConfig,
105
+ data: BacklogReleases, result: AddedToJira) -> None:
106
+ """Write the added and already-present backlogs to any named files."""
107
+ releases = list(data.releases)
108
+ if parsed.added_file is not None:
109
+ added = BacklogReleases(backlog=result.stored, releases=releases)
110
+ _write_backlog_file(config, parsed.added_file, added, parsed.force)
111
+ if parsed.existing_file is not None:
112
+ present = BacklogReleases(backlog=result.already_present,
113
+ releases=releases)
114
+ _write_backlog_file(config, parsed.existing_file, present,
115
+ parsed.force)
116
+
117
+
118
+ def _run(parsed: argparse.Namespace) -> int:
119
+ """Read the input, add it to Jira, write files and print the lists."""
120
+ try:
121
+ config = required_config(parsed)
122
+ data = read_input(parsed, config)
123
+ result = _add(parsed, config, data)
124
+ _write_result_files(parsed, config, data, result)
125
+ except ExistsInJiraError:
126
+ print('Nothing added to Jira.', file=sys.stderr)
127
+ return 1
128
+ except (ValueError, TypeError, KeyError, OSError) as error:
129
+ print(f'Could not add to Jira: {error}', file=sys.stderr)
130
+ return 1
131
+ if not parsed.quiet:
132
+ print(format_add_result(result))
133
+ return 0
134
+
135
+
136
+ def main(args: Optional[list[str]] = None) -> int:
137
+ """Add a backlog to Jira and report the added and present items.
138
+
139
+ Args:
140
+ args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
141
+
142
+ Returns:
143
+ ``0`` on success, ``1`` when the backlog cannot be added or a key
144
+ already exists in Jira without ``--skip-existing``.
145
+ """
146
+ parsed = parsed_args(build_parser(), args)
147
+ return _run(parsed)
148
+
149
+
150
+ if __name__ == '__main__': # pragma: no cover
151
+ sys.exit(main())
@@ -8,8 +8,10 @@
8
8
  import argparse
9
9
  import sys
10
10
  from typing import Optional
11
- from backlogops import backlog_ops_wizard
11
+ from backlogops import BacklogOpsConfig, backlog_ops_wizard, \
12
+ read_backlog_ops_config
12
13
  from backlogops_cli._command_io import parsed_args
14
+ from backlogops_cli._migrate_warn import CliMigrateWarnHook
13
15
  from backlogops_cli._wizard_io import build_wizard_parser, run_wizard_to_file
14
16
 
15
17
  DESCRIPTION = 'Create a backlog-ops configuration file via a wizard'
@@ -20,21 +22,30 @@ def build_parser() -> argparse.ArgumentParser:
20
22
  return build_wizard_parser(DESCRIPTION)
21
23
 
22
24
 
25
+ def _read_config(filename: str) -> BacklogOpsConfig:
26
+ """Read an existing backlog-ops configuration to pre-fill the wizard."""
27
+ return read_backlog_ops_config(filename, sys.stderr,
28
+ auto_ch_hook=CliMigrateWarnHook())
29
+
30
+
23
31
  def main(args: Optional[list[str]] = None) -> int:
24
32
  """Run the interactive wizard and write the backlog-ops configuration.
25
33
 
26
- The output filename receives the ``.cfg`` extension when it is not
27
- already present.
34
+ With ``-i`` an existing configuration file is read first and used to
35
+ pre-fill the wizard, so the user edits those values; pointing ``-i`` at
36
+ the same file as ``-o`` edits it in place after confirming the
37
+ overwrite. The output filename receives the ``.cfg`` extension when it
38
+ is not already present.
28
39
 
29
40
  Args:
30
41
  args: Optional replacement for ``sys.argv[1:]``, mainly for tests.
31
42
 
32
43
  Returns:
33
44
  ``0`` on success, ``1`` when the entered configuration is rejected
34
- or cannot be written.
45
+ or cannot be read or written.
35
46
  """
36
47
  parsed = parsed_args(build_parser(), args)
37
- return run_wizard_to_file(parsed, backlog_ops_wizard,
48
+ return run_wizard_to_file(parsed, backlog_ops_wizard, _read_config,
38
49
  'Backlog-ops configuration')
39
50
 
40
51
 
@@ -0,0 +1,120 @@
1
+ #!/usr/local/bin/python3
2
+ """Encrypt a Jira API token to a file with a pass phrase.
3
+
4
+ Read a clear text API token from a file or from standard input, encrypt it
5
+ with a pass phrase and write the encrypted token to a file. The pass phrase
6
+ is requested on the terminal and is not echoed. The clear text token file
7
+ is not modified, unless the the encrypted token file is the same as the
8
+ clear text token file, in which case the clear text token file is overwritten
9
+ with the encrypted token. The encrypted token file is written atomically,
10
+ so that it is either the old version or the new version, and never a
11
+ half-written file.
12
+ """
13
+
14
+ # PYTHON_ARGCOMPLETE_OK
15
+ # Copyright (c) 2026, Tom Björkholm
16
+ # MIT License
17
+
18
+ import argparse
19
+ import sys
20
+ from getpass import getpass
21
+ from pathlib import Path
22
+ from typing import Optional
23
+ from backlogops import encrypt_token_to_file, encrypt_token_file
24
+ from backlogops_cli._command_io import parsed_args
25
+
26
+
27
+ DESCRIPTION = 'Encrypt a Jira API token file with a pass phrase.'
28
+
29
+
30
+ def _ok_to_overwrite(filename: Path) -> bool:
31
+ """Return True if the file can be overwritten, False otherwise."""
32
+ txt = f'The output file {filename} already exists. Overwrite? [y/n] '
33
+ while True:
34
+ answer = input(txt)
35
+ if answer.lower().strip() in ('y', 'ye', 'yes'):
36
+ return True
37
+ if answer.lower().strip() in ('n', 'no'):
38
+ return False
39
+ print('Please answer yes or no.')
40
+ return False # pragma: no cover
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ """Build the command line parser for the encrypt-token-file command."""
45
+ descr = DESCRIPTION + '. The pass phrase is requested on the terminal.'
46
+ descr += ' The clear text token file is not modified, unless the output'
47
+ descr += ' file is the same as the input file, in which case the clear'
48
+ descr += ' text token file is overwritten with the encrypted token.'
49
+ parser = argparse.ArgumentParser(description=descr)
50
+ inhelp = 'The clear text token file to read from. Omit to read from stdin.'
51
+ parser.add_argument('-i', '--infile', required=False, help=inhelp)
52
+ parser.add_argument('-o', '--outfile', required=True,
53
+ help='The encrypted token file to write to.')
54
+ return parser
55
+
56
+
57
+ EXCEPTIONS = (FileNotFoundError, ValueError, FileExistsError,
58
+ NotADirectoryError, OSError)
59
+
60
+
61
+ def _run(parsed: argparse.Namespace) -> int:
62
+ """Run the encrypt-token-file command with the given parsed arguments.
63
+
64
+ Args:
65
+ parsed: The parsed command line arguments.
66
+ Returns:
67
+ 0 if the command was successful, 1 if there was an error
68
+ """
69
+ outfile = Path(parsed.outfile)
70
+ token: Optional[str] = None
71
+ pphrase1 = 'a'
72
+ phrase2 = 'b'
73
+ while pphrase1 != phrase2 or not pphrase1:
74
+ pphrase1 = getpass('Enter the pass phrase to encrypt the token with: ')
75
+ if not pphrase1:
76
+ print('No pass phrase entered. Exiting.')
77
+ return 1
78
+ phrase2 = getpass('Re-enter the pass phrase to confirm: ')
79
+ if pphrase1 != phrase2:
80
+ print('Pass phrases do not match. Please try again.')
81
+ if not parsed.infile:
82
+ print('Enter the clear text token on a single line, then press Enter.')
83
+ token = input().strip()
84
+ if not token:
85
+ print('No token entered. Exiting.')
86
+ return 1
87
+ try:
88
+ encrypt_token_to_file(token, passphrase=pphrase1, filename=outfile,
89
+ ok_to_overwrite=_ok_to_overwrite)
90
+ return 0
91
+ except EXCEPTIONS as e:
92
+ print(f'Error encrypting token: {e}')
93
+ return 1
94
+ infile = Path(parsed.infile)
95
+ try:
96
+ encrypt_token_file(clear_file=infile, encrypted_file=outfile,
97
+ passphrase=pphrase1,
98
+ ok_to_overwrite=_ok_to_overwrite)
99
+ return 0
100
+ except EXCEPTIONS as e:
101
+ print(f'Error encrypting token file: {e}')
102
+ return 1
103
+ return 2 # pragma: no cover # this should never be reached
104
+
105
+
106
+ def main(arguments: Optional[list[str]] = None) -> int:
107
+ """Encrypt a Jira API token file with a pass phrase.
108
+
109
+ Args:
110
+ arguments: The command line arguments to parse. If None, the arguments
111
+ are taken from sys.argv.
112
+ Returns:
113
+ 0 if the command was successful, 1 if there was an error.
114
+ """
115
+ parsed = parsed_args(build_parser(), arguments)
116
+ return _run(parsed)
117
+
118
+
119
+ if __name__ == '__main__': # pragma: no cover
120
+ sys.exit(main())