galactic 0.4.0.0.post1.dev132__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.
@@ -0,0 +1,27 @@
1
+ """
2
+ Defines the public API for the CLI application module.
3
+ """
4
+
5
+ __all__ = (
6
+ "application",
7
+ "error",
8
+ "info",
9
+ "warning",
10
+ "comment",
11
+ "prompt",
12
+ "confirm",
13
+ "AppStyles",
14
+ "Group",
15
+ )
16
+
17
+ from ._app import (
18
+ AppStyles,
19
+ Group,
20
+ application,
21
+ comment,
22
+ confirm,
23
+ error,
24
+ info,
25
+ prompt,
26
+ warning,
27
+ )
@@ -0,0 +1,22 @@
1
+ from typing import Any, ClassVar
2
+
3
+ import rich_click as click
4
+
5
+ class Group(click.Group): # type: ignore[misc]
6
+ ...
7
+
8
+ application: Group
9
+
10
+ def error(text: str, **kwargs: Any) -> None: ...
11
+ def info(text: str, **kwargs: Any) -> None: ...
12
+ def warning(text: str, **kwargs: Any) -> None: ...
13
+ def comment(text: str, **kwargs: Any) -> None: ...
14
+ def prompt(text: str, **kwargs: Any) -> Any: ...
15
+ def confirm(text: str, **kwargs: Any) -> bool: ...
16
+
17
+ class AppStyles:
18
+ ERROR: ClassVar[dict[str, Any]]
19
+ WARNING: ClassVar[dict[str, Any]]
20
+ INFO: ClassVar[dict[str, Any]]
21
+ COMMENT: ClassVar[dict[str, Any]]
22
+ QUESTION: ClassVar[dict[str, Any]]
@@ -0,0 +1,335 @@
1
+ """
2
+ Main app module.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from typing import Any, ClassVar
9
+
10
+ import rich_click as click
11
+
12
+ from galactic.helpers.core import detect_plugins
13
+
14
+ from ._pip_config import (
15
+ extract_url_username,
16
+ list_extra_index_urls,
17
+ redact_url_credentials,
18
+ resolve_pip_config_path,
19
+ write_pip_extra_index_url,
20
+ )
21
+
22
+
23
+ # pylint: disable=too-few-public-methods
24
+ class AppStyles:
25
+ """
26
+ Styles used in application.
27
+
28
+ Attributes
29
+ ----------
30
+ ERROR
31
+ Style for errors (``{"fg": "red", "bold": True}`` by default).
32
+ WARNING
33
+ Style for warnings (``{"fg": "yellow", "bold": True}`` by default).
34
+ INFO
35
+ Style for informations (``{"fg": "bright_magenta"}`` by default).
36
+ COMMENT
37
+ Style for comments (``{"fg": "green"}`` by default).
38
+ QUESTION
39
+ Style for questions (``{"fg": "cyan"}`` by default).
40
+ """
41
+
42
+ ERROR: ClassVar[dict[str, Any]] = {"fg": "red", "bold": True}
43
+ WARNING: ClassVar[dict[str, Any]] = {"fg": "yellow", "bold": True}
44
+ INFO: ClassVar[dict[str, Any]] = {"fg": "bright_magenta"}
45
+ COMMENT: ClassVar[dict[str, Any]] = {"fg": "green"}
46
+ QUESTION: ClassVar[dict[str, Any]] = {"fg": "cyan"}
47
+
48
+
49
+ def error(text: str, **kwargs: Any) -> None:
50
+ """
51
+ Display an error message.
52
+
53
+ Parameters
54
+ ----------
55
+ text
56
+ Text to display
57
+ **kwargs
58
+ Additional parameters passed to click.secho
59
+
60
+ """
61
+ click.secho(text, **AppStyles.ERROR, **kwargs)
62
+
63
+
64
+ def info(text: str, **kwargs: Any) -> None:
65
+ """
66
+ Display an info message.
67
+
68
+ Parameters
69
+ ----------
70
+ text
71
+ Text to display
72
+ **kwargs
73
+ Additional parameters passed to click.secho
74
+
75
+ """
76
+ click.secho(text, **AppStyles.INFO, **kwargs)
77
+
78
+
79
+ def warning(text: str, **kwargs: Any) -> None:
80
+ """
81
+ Display a warning.
82
+
83
+ Parameters
84
+ ----------
85
+ text
86
+ Text to display
87
+ **kwargs
88
+ Additional parameters passed to click.secho
89
+
90
+ """
91
+ click.secho(text, **AppStyles.WARNING, **kwargs)
92
+
93
+
94
+ def comment(text: str, **kwargs: Any) -> None:
95
+ """
96
+ Display a comment.
97
+
98
+ Parameters
99
+ ----------
100
+ text
101
+ Text to display
102
+ **kwargs
103
+ Additional parameters passed to click.secho
104
+
105
+ """
106
+ click.secho(text, **AppStyles.COMMENT, **kwargs)
107
+
108
+
109
+ def prompt(text: str, **kwargs: Any) -> Any:
110
+ """
111
+ Prompt the user.
112
+
113
+ Parameters
114
+ ----------
115
+ text
116
+ Text to display
117
+ **kwargs
118
+ Additional parameters passed to click.prompt
119
+
120
+ """
121
+ return click.prompt(click.style(text, **AppStyles.QUESTION), **kwargs)
122
+
123
+
124
+ def confirm(text: str, **kwargs: Any) -> bool:
125
+ """
126
+ Prompt the user for a confirmation.
127
+
128
+ Parameters
129
+ ----------
130
+ text
131
+ Text to display
132
+ **kwargs
133
+ Additional parameters passed to click.confirm
134
+
135
+ """
136
+ return click.confirm(click.style(text, **AppStyles.QUESTION), **kwargs)
137
+
138
+
139
+ def prompt_password_with_confirmation(text: str) -> str:
140
+ """
141
+ Prompt a hidden password twice and allow empty values.
142
+
143
+ Parameters
144
+ ----------
145
+ text
146
+ Prompt displayed for the first password entry.
147
+
148
+ Returns
149
+ -------
150
+ str
151
+ Confirmed password.
152
+
153
+ """
154
+ while True:
155
+ password = prompt(
156
+ text,
157
+ default="",
158
+ hide_input=True,
159
+ show_default=False,
160
+ type=str,
161
+ )
162
+ confirmation = prompt(
163
+ "Confirm password",
164
+ default="",
165
+ hide_input=True,
166
+ show_default=False,
167
+ type=str,
168
+ )
169
+ if password == confirmation:
170
+ return password
171
+ error("Passwords do not match. Please retry.")
172
+
173
+
174
+ # pylint: disable=too-few-public-methods
175
+ class AppEnvVars:
176
+ """
177
+ Environment variables.
178
+
179
+ Attributes
180
+ ----------
181
+ NO_COLOR
182
+ Name of environment variable for no-color.
183
+ FORCE_COLOR
184
+ Name of environment variable for force color.
185
+ """
186
+
187
+ # https://no-color.org
188
+ NO_COLOR = "NO_COLOR"
189
+ FORCE_COLOR = "FORCE_COLOR"
190
+
191
+
192
+ class Group(click.Group): # type: ignore[misc]
193
+ """
194
+ Group class for the GALACTIC app.
195
+
196
+ It does two things:
197
+
198
+ * it detects plugins from the galactic.apps.cli.main group.
199
+ * it adds an epilog to all added commands.
200
+ """
201
+
202
+ _plugins_detected = False
203
+
204
+ @classmethod
205
+ def detect_plugins(cls) -> None:
206
+ """
207
+ Detect plugins from the galactic.apps.cli.main group.
208
+ """
209
+ if not cls._plugins_detected:
210
+ detect_plugins(group="galactic.apps.cli.main")
211
+ cls._plugins_detected = True
212
+
213
+ def list_commands(self, ctx: click.Context) -> list[str]:
214
+ """
215
+ List commands in the group.
216
+
217
+ Parameters
218
+ ----------
219
+ ctx
220
+ The click context
221
+
222
+ Returns
223
+ -------
224
+ list[str]
225
+ List of command names in the group
226
+
227
+ """
228
+ self.detect_plugins()
229
+ return super().list_commands(ctx)
230
+
231
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
232
+ """
233
+ Get a command by name.
234
+
235
+ Parameters
236
+ ----------
237
+ ctx
238
+ The click context
239
+ cmd_name
240
+ The name of the command to get
241
+
242
+ Returns
243
+ -------
244
+ click.Command | None
245
+ The command if found, otherwise None
246
+
247
+ """
248
+ self.detect_plugins()
249
+ return super().get_command(ctx, cmd_name)
250
+
251
+ def add_command(self, cmd: click.Command, name: str | None = None) -> None:
252
+ """
253
+ Add a command to the group.
254
+
255
+ This method also sets the epilog of the command to the application's epilog.
256
+ It is useful for commands that are part of the application and should
257
+ inherit the application's epilog.
258
+
259
+ Parameters
260
+ ----------
261
+ cmd
262
+ The command to add
263
+ name
264
+ The name of the command (if None, the name of the command is used)
265
+
266
+ """
267
+ cmd.epilog = cmd.epilog or application.epilog
268
+ super().add_command(cmd, name)
269
+
270
+
271
+ @click.version_option(package_name="galactic") # type: ignore[untyped-decorator]
272
+ @click.group( # type: ignore[untyped-decorator]
273
+ name="galactic",
274
+ cls=Group,
275
+ epilog="GAlois LAttices, Concept Theory, Implicational systems and Closures.",
276
+ )
277
+ @click.pass_context # type: ignore[untyped-decorator]
278
+ def application(ctx: click.Context) -> int:
279
+ """
280
+ GALACTIC main application.
281
+ """
282
+ if os.environ.get(AppEnvVars.NO_COLOR) == "1":
283
+ ctx.color = False
284
+ elif os.environ.get(AppEnvVars.FORCE_COLOR) == "1":
285
+ ctx.color = True
286
+ return 0
287
+
288
+
289
+ @application.command() # type: ignore[untyped-decorator]
290
+ def config() -> None:
291
+ """
292
+ Configure access to the GALACTIC package index.
293
+ """
294
+ config_path = resolve_pip_config_path()
295
+ backup_path = config_path.with_name(f"{config_path.name}.old")
296
+ had_existing_config = config_path.exists()
297
+ existing_urls = list_extra_index_urls(config_path)
298
+
299
+ selected_url: str | None = None
300
+ if len(existing_urls) > 1:
301
+ info("Several extra-index-url entries were found:")
302
+ for idx, url in enumerate(existing_urls, start=1):
303
+ comment(f"{idx}. {redact_url_credentials(url)}")
304
+ selected_index = prompt(
305
+ "Which index URL do you want to configure?",
306
+ default=1,
307
+ type=click.IntRange(min=1, max=len(existing_urls)),
308
+ show_default=True,
309
+ )
310
+ selected_url = existing_urls[selected_index - 1]
311
+ elif len(existing_urls) == 1:
312
+ selected_url = existing_urls[0]
313
+
314
+ if selected_url is not None and not confirm(
315
+ "Index URL already present in "
316
+ f"{config_path}: {redact_url_credentials(selected_url)}. "
317
+ "Update credentials?",
318
+ default=False,
319
+ ):
320
+ return
321
+
322
+ default_username = extract_url_username(selected_url) if selected_url else None
323
+ username = prompt(
324
+ "Username",
325
+ default=default_username or "__token__",
326
+ show_default=True,
327
+ type=str,
328
+ )
329
+ password = prompt_password_with_confirmation(
330
+ "Password (hit enter if you have no password)",
331
+ )
332
+ write_pip_extra_index_url(username, password, config_path, selected_url)
333
+ if had_existing_config:
334
+ warning(f"Previous configuration stored in {backup_path}.")
335
+ info(f"Stored credentials in {config_path}.")
@@ -0,0 +1,516 @@
1
+ """
2
+ Utilities for pip configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from configparser import ConfigParser
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
15
+
16
+ import platformdirs
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+
22
+ PIP_CONFIG_FILE_ENV_VAR = "PIP_CONFIG_FILE"
23
+ VIRTUAL_ENV_ENV_VAR = "VIRTUAL_ENV"
24
+ CONDA_PREFIX_ENV_VAR = "CONDA_PREFIX"
25
+ WINDOWS_PIP_CONFIG_FILENAME = "pip.ini"
26
+ UNIX_PIP_CONFIG_FILENAME = "pip.conf"
27
+ INDEX_HOSTNAME = "www.thegalactic.org"
28
+ PACKAGE_INDEX_URL = "https://www.thegalactic.org/pypi/simple/"
29
+ GLOBAL_SECTION = "global"
30
+ EXTRA_INDEX_URL_OPTION = "extra-index-url"
31
+
32
+
33
+ def pip_config_filename() -> str:
34
+ """
35
+ Return the pip configuration filename for the current platform.
36
+
37
+ Returns
38
+ -------
39
+ str
40
+ ``pip.ini`` on Windows, ``pip.conf`` elsewhere.
41
+
42
+ """
43
+ if sys.platform.startswith("win"):
44
+ return WINDOWS_PIP_CONFIG_FILENAME
45
+ return UNIX_PIP_CONFIG_FILENAME
46
+
47
+
48
+ def resolve_pip_config_path(
49
+ *,
50
+ env: Mapping[str, str] | None = None,
51
+ ) -> Path:
52
+ """
53
+ Resolve the pip configuration file path.
54
+
55
+ The resolution follows this priority order:
56
+
57
+ 1. ``PIP_CONFIG_FILE`` environment variable (explicit override).
58
+ 3. ``CONDA_PREFIX`` — path inside the active conda environment.
59
+ 2. ``VIRTUAL_ENV`` — path inside the active virtual environment.
60
+ 4. User-level config directory resolved by :mod:`platformdirs`.
61
+
62
+ Parameters
63
+ ----------
64
+ env
65
+ Environment variables mapping. Defaults to :data:`os.environ`.
66
+
67
+ Returns
68
+ -------
69
+ Path
70
+ The path to the pip configuration file.
71
+
72
+ """
73
+ environment: Mapping[str, str] = env if env is not None else os.environ
74
+ filename = pip_config_filename()
75
+
76
+ # Priority 1: explicit override
77
+ if pip_config_file := environment.get(PIP_CONFIG_FILE_ENV_VAR):
78
+ return Path(pip_config_file)
79
+
80
+ # Priority 2: inside a conda environment
81
+ if conda_prefix := environment.get(CONDA_PREFIX_ENV_VAR):
82
+ return Path(conda_prefix) / filename
83
+
84
+ # Priority 3: inside a virtual environment
85
+ if virtual_env := environment.get(VIRTUAL_ENV_ENV_VAR):
86
+ return Path(virtual_env) / filename
87
+
88
+ # Priority 4: user-level config resolved by platformdirs
89
+ return platformdirs.user_config_path("pip") / filename
90
+
91
+
92
+ def build_package_index_url(password: str, username: str = "__token__") -> str:
93
+ """
94
+ Build the authenticated GALACTIC package index URL.
95
+
96
+ Parameters
97
+ ----------
98
+ password
99
+ Password to embed in the URL.
100
+ username
101
+ Username to embed in the URL (``__token__`` by default).
102
+
103
+ Returns
104
+ -------
105
+ str
106
+ The package index URL with credentials embedded.
107
+
108
+ """
109
+ return with_url_credentials(PACKAGE_INDEX_URL, username=username, password=password)
110
+
111
+
112
+ def list_extra_index_urls(path: Path) -> list[str]:
113
+ """
114
+ Return configured ``extra-index-url`` entries from a pip config file.
115
+
116
+ Parameters
117
+ ----------
118
+ path
119
+ Path to the pip configuration file.
120
+
121
+ Returns
122
+ -------
123
+ list[str]
124
+ Parsed URL entries, in file order.
125
+
126
+ """
127
+ if not path.exists():
128
+ return []
129
+
130
+ config = ConfigParser(interpolation=None)
131
+ config.read(path, encoding="utf-8")
132
+ existing_raw = config.get(GLOBAL_SECTION, EXTRA_INDEX_URL_OPTION, fallback="")
133
+ return [url for raw in existing_raw.splitlines() for url in raw.split() if url]
134
+
135
+
136
+ def with_url_credentials(url: str, *, username: str, password: str) -> str:
137
+ """
138
+ Return ``url`` with explicit credentials in its netloc.
139
+
140
+ Parameters
141
+ ----------
142
+ url
143
+ Base URL to update.
144
+ username
145
+ Username to inject.
146
+ password
147
+ Password to inject.
148
+
149
+ Returns
150
+ -------
151
+ str
152
+ URL containing ``username:password`` credentials.
153
+
154
+ """
155
+ parsed = urlsplit(url)
156
+ if not parsed.scheme or not parsed.hostname:
157
+ return url
158
+
159
+ user = quote(username, safe="")
160
+ secret = quote(password, safe="")
161
+
162
+ host = _as_text(parsed.hostname)
163
+ if parsed.port is not None:
164
+ host = f"{host}:{parsed.port}"
165
+ if user:
166
+ host = f"{user}:{secret}@{host}"
167
+
168
+ scheme = _as_text(parsed.scheme)
169
+ path = _as_text(parsed.path)
170
+ query = _as_text(parsed.query)
171
+ fragment = _as_text(parsed.fragment)
172
+
173
+ return urlunsplit((scheme, host, path, query, fragment))
174
+
175
+
176
+ def redact_url_credentials(url: str) -> str:
177
+ """
178
+ Return ``url`` without embedded credentials.
179
+
180
+ Parameters
181
+ ----------
182
+ url
183
+ URL potentially containing ``username:password``.
184
+
185
+ Returns
186
+ -------
187
+ str
188
+ URL with user-info stripped from netloc.
189
+
190
+ """
191
+ parsed = urlsplit(url)
192
+ if not parsed.scheme or not parsed.hostname:
193
+ return url
194
+
195
+ host = _as_text(parsed.hostname)
196
+ if parsed.port is not None:
197
+ host = f"{host}:{parsed.port}"
198
+
199
+ scheme = _as_text(parsed.scheme)
200
+ path = _as_text(parsed.path)
201
+ query = _as_text(parsed.query)
202
+ fragment = _as_text(parsed.fragment)
203
+ return urlunsplit((scheme, host, path, query, fragment))
204
+
205
+
206
+ def extract_url_username(url: str) -> str | None:
207
+ """
208
+ Extract the username embedded in ``url`` credentials.
209
+
210
+ Parameters
211
+ ----------
212
+ url
213
+ URL that may contain user-info.
214
+
215
+ Returns
216
+ -------
217
+ str | None
218
+ Decoded username when present, otherwise ``None``.
219
+
220
+ """
221
+ parsed = urlsplit(url)
222
+ if parsed.username is None:
223
+ return None
224
+ username = unquote(_as_text(parsed.username))
225
+ return username or None
226
+
227
+
228
+ def is_galactic_url_configured(path: Path) -> bool:
229
+ """
230
+ Check whether a GALACTIC index URL is already present in a pip config file.
231
+
232
+ Parameters
233
+ ----------
234
+ path
235
+ Path to the pip configuration file.
236
+
237
+ Returns
238
+ -------
239
+ bool
240
+ ``True`` if at least one ``extra-index-url`` entry references
241
+ :data:`INDEX_HOSTNAME`, ``False`` otherwise (including when the file
242
+ does not exist).
243
+
244
+ """
245
+ return any(INDEX_HOSTNAME in url for url in list_extra_index_urls(path))
246
+
247
+
248
+ def write_pip_extra_index_url(
249
+ username: str,
250
+ password: str,
251
+ path: Path,
252
+ target_url: str | None = None,
253
+ ) -> str:
254
+ """
255
+ Write the GALACTIC extra index URL to a pip configuration file.
256
+
257
+ Parameters
258
+ ----------
259
+ username
260
+ Username to store in the authenticated URL.
261
+ password
262
+ Password to store in the authenticated URL.
263
+ path
264
+ Path to the pip configuration file.
265
+ target_url
266
+ Existing URL to update. If ``None``, use the GALACTIC URL when no
267
+ ``extra-index-url`` is configured.
268
+
269
+ Returns
270
+ -------
271
+ str
272
+ Final URL that has been configured with credentials.
273
+
274
+ """
275
+ all_urls = list_extra_index_urls(path)
276
+
277
+ selected_url = target_url
278
+ if selected_url is None and not all_urls:
279
+ selected_url = PACKAGE_INDEX_URL
280
+ elif selected_url is None and len(all_urls) == 1:
281
+ selected_url = all_urls[0]
282
+
283
+ if selected_url is None:
284
+ raise ValueError(
285
+ "target_url must be provided when multiple URLs are configured",
286
+ )
287
+
288
+ configured_url = with_url_credentials(
289
+ selected_url,
290
+ username=username,
291
+ password=password,
292
+ )
293
+
294
+ updated = False
295
+ for index, url in enumerate(all_urls):
296
+ if url == selected_url:
297
+ all_urls[index] = configured_url
298
+ updated = True
299
+ break
300
+
301
+ if not updated:
302
+ all_urls.append(configured_url)
303
+
304
+ new_content = _upsert_extra_index_url_option(
305
+ original_content=path.read_text(encoding="utf-8") if path.exists() else "",
306
+ urls=all_urls,
307
+ )
308
+
309
+ path.parent.mkdir(parents=True, exist_ok=True)
310
+ if path.exists():
311
+ backup_path = path.with_name(f"{path.name}.old")
312
+ shutil.copy2(path, backup_path)
313
+ path.write_text(new_content, encoding="utf-8")
314
+
315
+ return configured_url
316
+
317
+
318
+ # pylint: disable=too-many-locals, too-many-branches
319
+ def _upsert_extra_index_url_option(*, original_content: str, urls: list[str]) -> str:
320
+ """Update only `[global].extra-index-url` while preserving surrounding text."""
321
+ newline = _detect_newline(original_content)
322
+ section_re = re.compile(r"^\s*\[(?P<name>[^\]]+)\]\s*$", re.IGNORECASE)
323
+ option_re = re.compile(r"^(?P<indent>\s*)extra-index-url\s*=.*$", re.IGNORECASE)
324
+
325
+ lines = original_content.splitlines(keepends=True)
326
+ section_start: int | None = None
327
+ section_end = len(lines)
328
+
329
+ for index, line in enumerate(lines):
330
+ match = section_re.match(line.rstrip("\r\n"))
331
+ if match and match.group("name").strip().lower() == GLOBAL_SECTION:
332
+ section_start = index
333
+ for after in range(index + 1, len(lines)):
334
+ if section_re.match(lines[after].rstrip("\r\n")):
335
+ section_end = after
336
+ break
337
+ break
338
+
339
+ replacement = _render_extra_index_option_lines(
340
+ urls,
341
+ newline=newline,
342
+ indent="",
343
+ )
344
+
345
+ if section_start is None:
346
+ if lines and not lines[-1].endswith(("\n", "\r")):
347
+ lines[-1] = lines[-1] + newline
348
+ if lines and lines[-1].strip():
349
+ lines.append(newline)
350
+ lines.append(f"[{GLOBAL_SECTION}]{newline}")
351
+ lines.extend(replacement)
352
+ return "".join(lines)
353
+
354
+ option_start: int | None = None
355
+ option_end = section_end
356
+ option_indent = ""
357
+ for index in range(section_start + 1, section_end):
358
+ stripped = lines[index].rstrip("\r\n")
359
+ match = option_re.match(stripped)
360
+ if match:
361
+ option_start = index
362
+ option_indent = match.group("indent")
363
+ for after in range(index + 1, section_end):
364
+ candidate = lines[after].rstrip("\r\n")
365
+ if not candidate:
366
+ continue
367
+ if candidate.startswith((" ", "\t")):
368
+ continue
369
+ if section_re.match(candidate):
370
+ break
371
+ option_end = after
372
+ break
373
+ else:
374
+ option_end = section_end
375
+ break
376
+
377
+ if option_start is None:
378
+ insertion_at = section_end
379
+ if insertion_at > section_start + 1 and lines[insertion_at - 1].strip():
380
+ lines.insert(insertion_at, newline)
381
+ insertion_at += 1
382
+ lines[insertion_at:insertion_at] = _render_extra_index_option_lines(
383
+ urls,
384
+ newline=newline,
385
+ indent="",
386
+ )
387
+ return "".join(lines)
388
+
389
+ lines[option_start:option_end] = _rewrite_option_block_preserving_positions(
390
+ lines[option_start:option_end],
391
+ urls,
392
+ newline=newline,
393
+ option_indent=option_indent,
394
+ )
395
+ return "".join(lines)
396
+
397
+
398
+ def _render_extra_index_option_lines(
399
+ urls: list[str],
400
+ *,
401
+ newline: str,
402
+ indent: str,
403
+ ) -> list[str]:
404
+ if len(urls) == 1:
405
+ return [f"{indent}{EXTRA_INDEX_URL_OPTION} = {urls[0]}{newline}"]
406
+ rendered = [f"{indent}{EXTRA_INDEX_URL_OPTION} ={newline}"]
407
+ rendered.extend(f"{indent} {url}{newline}" for url in urls)
408
+ return rendered
409
+
410
+
411
+ def _rewrite_option_block_preserving_positions(
412
+ original_option_lines: list[str],
413
+ urls: list[str],
414
+ *,
415
+ newline: str,
416
+ option_indent: str,
417
+ ) -> list[str]:
418
+ """Rewrite URL lines in an option block while keeping other lines unchanged."""
419
+ if not original_option_lines:
420
+ return _render_extra_index_option_lines(
421
+ urls,
422
+ newline=newline,
423
+ indent=option_indent,
424
+ )
425
+
426
+ url_slots: list[tuple[str, str]] = []
427
+ passthrough: dict[int, str] = {}
428
+
429
+ first = original_option_lines[0].rstrip("\r\n")
430
+ first_match = re.match(
431
+ r"^(?P<prefix>\s*extra-index-url\s*=\s*)(?P<value>.*)$",
432
+ first,
433
+ re.IGNORECASE,
434
+ )
435
+ if first_match is None:
436
+ return _render_extra_index_option_lines(
437
+ urls,
438
+ newline=newline,
439
+ indent=option_indent,
440
+ )
441
+
442
+ first_value = first_match.group("value").strip()
443
+ first_prefix = first_match.group("prefix")
444
+ if _is_url_value_line(first_value):
445
+ url_slots.append((first_prefix, "first"))
446
+ else:
447
+ passthrough[0] = (
448
+ f"{first_prefix}{newline}" if not first_value else f"{first}{newline}"
449
+ )
450
+
451
+ for index, raw_line in enumerate(original_option_lines[1:], start=1):
452
+ line = raw_line.rstrip("\r\n")
453
+ stripped = line.strip()
454
+ if not stripped or stripped.startswith(("#", ";")):
455
+ passthrough[index] = f"{line}{newline}"
456
+ continue
457
+ indent_match = re.match(r"^(\s*)", line)
458
+ slot_prefix = indent_match.group(1) if indent_match else ""
459
+ url_slots.append((slot_prefix, "cont"))
460
+
461
+ result: list[str] = []
462
+ url_index = 0
463
+ total_lines = len(original_option_lines)
464
+ slot_index = 0
465
+
466
+ for index in range(total_lines):
467
+ if index in passthrough:
468
+ result.append(passthrough[index])
469
+ continue
470
+ if url_index >= len(urls):
471
+ slot_index += 1
472
+ continue
473
+ prefix = url_slots[slot_index][0]
474
+ result.append(f"{prefix}{urls[url_index]}{newline}")
475
+ url_index += 1
476
+ slot_index += 1
477
+
478
+ while url_index < len(urls):
479
+ if url_slots:
480
+ base_prefix = url_slots[-1][0]
481
+ continuation_prefix = (
482
+ base_prefix if base_prefix.strip() else f"{option_indent} "
483
+ )
484
+ else:
485
+ continuation_prefix = f"{option_indent} "
486
+ result.append(f"{continuation_prefix}{urls[url_index]}{newline}")
487
+ url_index += 1
488
+
489
+ if result and not result[0].lower().lstrip().startswith("extra-index-url"):
490
+ return _render_extra_index_option_lines(
491
+ urls,
492
+ newline=newline,
493
+ indent=option_indent,
494
+ )
495
+
496
+ return result
497
+
498
+
499
+ def _is_url_value_line(value: str) -> bool:
500
+ if not value:
501
+ return False
502
+ return not value.startswith(("#", ";"))
503
+
504
+
505
+ def _detect_newline(content: str) -> str:
506
+ if "\r\n" in content:
507
+ return "\r\n"
508
+ return "\n"
509
+
510
+
511
+ def _as_text(value: str | bytes | None) -> str:
512
+ if value is None:
513
+ return ""
514
+ if isinstance(value, bytes):
515
+ return value.decode()
516
+ return value
File without changes
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: galactic
3
+ Version: 0.4.0.0.post1.dev132
4
+ Summary: GALACTIC main application
5
+ Project-URL: Homepage, https://www.thegalactic.org/
6
+ Project-URL: Documentation, https://www.thegalactic.org/docs/apps/cli/main/
7
+ Project-URL: Repository, https://gitlab.univ-lr.fr/galactic/public/src/apps/cli/galactic-app-cli-main
8
+ Project-URL: Issues, https://gitlab.univ-lr.fr/galactic/public/src/apps/cli/galactic-app-cli-main/-/issues
9
+ Project-URL: Icon, https://www.thegalactic.org/docs/apps/cli/main/latest/_static/icon.svg
10
+ Project-URL: Icon-dark, https://www.thegalactic.org/docs/apps/cli/main/latest/_static/icon-dark.svg
11
+ Project-URL: Index-public, https://www.thegalactic.org/pypi/public/simple/
12
+ Author-email: The Galactic Organization <contact@thegalactic.org>
13
+ Maintainer-email: The Galactic Organization <contact@thegalactic.org>
14
+ License-Expression: BSD-3-Clause
15
+ License-File: LICENSE
16
+ Keywords: application,formal concept analysis
17
+ Classifier: Development Status :: 4 - Beta
18
+ Classifier: Environment :: Console
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Intended Audience :: End Users/Desktop
21
+ Classifier: Natural Language :: English
22
+ Classifier: Operating System :: OS Independent
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Requires-Python: <3.15,>=3.11
28
+ Requires-Dist: click~=8.3
29
+ Requires-Dist: galactic-helper-core>=0.4.0.0.post1.dev
30
+ Requires-Dist: platformdirs~=4.9
31
+ Requires-Dist: rich-click~=1.9
32
+ Description-Content-Type: text/x-rst
33
+
34
+ Instructions
35
+ ============
36
+
37
+ |hatch|
38
+ |pre-commit|
39
+ |ruff|
40
+ |black|
41
+ |doc8|
42
+ |mypy|
43
+ |pylint|
44
+ |slotscheck|
45
+
46
+ .. |hatch| image:: https://img.shields.io/badge/%F0%9F%A5%9A-hatch-4051b5.svg
47
+ :target: https://pypi.org/project/hatch/
48
+ .. |pre-commit| image:: https://img.shields.io/badge/-pre--commit-brightgreen?logo=pre-commit&labelColor=gray
49
+ :target: https://pypi.org/project/pre-commit/
50
+ .. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
51
+ :target: https://pypi.org/project/ruff/
52
+ .. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg
53
+ :target: https://pypi.org/project/black/
54
+ .. |doc8| image:: https://img.shields.io/badge/static--checked-doc8-green
55
+ :target: https://pypi.org/project/doc8/
56
+ .. |mypy| image:: https://img.shields.io/badge/static--checked-mypy-blue
57
+ :target: https://pypi.org/project/mypy/
58
+ .. |pylint| image:: https://img.shields.io/badge/dynamic--checked-pylint-orange
59
+ :target: https://pypi.org/project/pylint/
60
+ .. |slotscheck| image:: https://img.shields.io/badge/dynamic--checked-slotscheck-orange
61
+ :target: https://pypi.org/project/slotscheck/
62
+
63
+ Prerequisite
64
+ ------------
65
+
66
+ *galactic* requires `python 3.11`_,
67
+ a programming language that comes pre-installed on linux and Mac OS X,
68
+ and which is easily installed `on Windows`_;
69
+
70
+ Installation
71
+ ------------
72
+
73
+ Install *galactic* in a virtual (``conda`` or ``venv``)
74
+ environment using the bash commands:
75
+
76
+ .. code-block:: shell-session
77
+
78
+ (galactic) $ pip install galactic
79
+
80
+ Don't forget to add the ``--pre`` flag if you want the latest unstable build.
81
+
82
+ After installation, you can configure authenticated access to the
83
+ **GALACTIC** package index with:
84
+
85
+ .. code-block:: shell-session
86
+
87
+ (galactic) $ galactic config
88
+
89
+ This command prompts for your GALACTIC password and stores it in your
90
+ ``pip.conf`` or ``pip.ini`` file.
91
+
92
+ Contributing
93
+ ------------
94
+
95
+ Build
96
+ ~~~~~
97
+
98
+ Building *galactic* requires
99
+
100
+ * `hatch`_, which is a tool for dependency management and packaging in Python;
101
+
102
+ Build *galactic* using the bash command
103
+
104
+ .. code-block:: shell-session
105
+
106
+ $ hatch build
107
+
108
+ Testing
109
+ ~~~~~~~
110
+
111
+ Test *galactic* using the bash command:
112
+
113
+ .. code-block:: shell-session
114
+
115
+ $ hatch test
116
+
117
+ for running the tests.
118
+
119
+ .. code-block:: shell-session
120
+
121
+ $ hatch test --cover
122
+
123
+ for running the tests with the coverage.
124
+
125
+ .. code-block:: shell-session
126
+
127
+ $ hatch test --doctest-modules src
128
+
129
+ for running the `doctest`.
130
+
131
+ Linting
132
+ ~~~~~~~
133
+
134
+ Lint *galactic* using the bash commands:
135
+
136
+ .. code-block:: shell-session
137
+
138
+ $ hatch fmt --check
139
+
140
+ for running static linting.
141
+
142
+ .. code-block:: shell-session
143
+
144
+ $ hatch fmt
145
+
146
+ for automatic fixing of static linting issues.
147
+
148
+ .. code-block:: shell-session
149
+
150
+ $ hatch run lint:check
151
+
152
+ for running dynamic linting.
153
+
154
+ Documentation
155
+ ~~~~~~~~~~~~~
156
+
157
+ Build the documentation using the bash commands:
158
+
159
+ .. code-block:: shell-session
160
+
161
+ $ hatch run docs:build
162
+
163
+ Getting Help
164
+ ~~~~~~~~~~~~
165
+
166
+ .. important::
167
+
168
+ If you have any difficulties with *galactic*, please feel welcome to
169
+ `file an issue`_ on GitLab so that we can help.
170
+
171
+ .. _file an issue: https://gitlab.univ-lr.fr/galactic/public/src/apps/cli/galactic-app-cli-main/-/issues
172
+ .. _python 3.11: http://www.python.org
173
+ .. _on Windows: https://www.python.org/downloads/windows
174
+ .. _hatch: https://hatch.pypa.io/
@@ -0,0 +1,10 @@
1
+ galactic/apps/cli/main/__init__.py,sha256=WTAPb5xi8j38e0Ol5zAs4l42BFwQO3qWi9JIbDI1Hsc,349
2
+ galactic/apps/cli/main/__init__.pyi,sha256=4IoZmUoosDWFDRh5mjLKH8F5YkaOmLQ21MDFGaX-1TY,642
3
+ galactic/apps/cli/main/_app.py,sha256=KXU8e-Rc2t96QtdlftQrB4_tCZG7YYenpeKo4h6uD6Q,8361
4
+ galactic/apps/cli/main/_pip_config.py,sha256=lFYQoAH6HL2VJBhCoTJz_BwSycX4rND7jat2egKlDQQ,14350
5
+ galactic/apps/cli/main/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ galactic-0.4.0.0.post1.dev132.dist-info/METADATA,sha256=Vs3Opf43t1uIH4LQNRwNl2jVfMf7-5tV1ixEvVoOIyM,4950
7
+ galactic-0.4.0.0.post1.dev132.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ galactic-0.4.0.0.post1.dev132.dist-info/entry_points.txt,sha256=xiTJDeF4B8NgXGgNE4bKmedDDGxf23Puwmfm1r3-N3o,64
9
+ galactic-0.4.0.0.post1.dev132.dist-info/licenses/LICENSE,sha256=9teLJoARXQ4o8xNJqYK8RmWCwTKvdLmdoHzqnStMAYE,1530
10
+ galactic-0.4.0.0.post1.dev132.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ galactic = galactic.apps.cli.main:application
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2018-2026, The Galactic Organization
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.