pyrig 2.2.6__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 (102) hide show
  1. pyrig/__init__.py +1 -0
  2. pyrig/dev/__init__.py +6 -0
  3. pyrig/dev/builders/__init__.py +1 -0
  4. pyrig/dev/builders/base/__init__.py +5 -0
  5. pyrig/dev/builders/base/base.py +256 -0
  6. pyrig/dev/builders/pyinstaller.py +229 -0
  7. pyrig/dev/cli/__init__.py +5 -0
  8. pyrig/dev/cli/cli.py +95 -0
  9. pyrig/dev/cli/commands/__init__.py +1 -0
  10. pyrig/dev/cli/commands/build_artifacts.py +16 -0
  11. pyrig/dev/cli/commands/create_root.py +25 -0
  12. pyrig/dev/cli/commands/create_tests.py +244 -0
  13. pyrig/dev/cli/commands/init_project.py +160 -0
  14. pyrig/dev/cli/commands/make_inits.py +27 -0
  15. pyrig/dev/cli/commands/protect_repo.py +145 -0
  16. pyrig/dev/cli/shared_subcommands.py +20 -0
  17. pyrig/dev/cli/subcommands.py +73 -0
  18. pyrig/dev/configs/__init__.py +1 -0
  19. pyrig/dev/configs/base/__init__.py +5 -0
  20. pyrig/dev/configs/base/base.py +826 -0
  21. pyrig/dev/configs/containers/__init__.py +1 -0
  22. pyrig/dev/configs/containers/container_file.py +111 -0
  23. pyrig/dev/configs/dot_env.py +95 -0
  24. pyrig/dev/configs/dot_python_version.py +88 -0
  25. pyrig/dev/configs/git/__init__.py +5 -0
  26. pyrig/dev/configs/git/gitignore.py +181 -0
  27. pyrig/dev/configs/git/pre_commit.py +170 -0
  28. pyrig/dev/configs/licence.py +112 -0
  29. pyrig/dev/configs/markdown/__init__.py +1 -0
  30. pyrig/dev/configs/markdown/docs/__init__.py +1 -0
  31. pyrig/dev/configs/markdown/docs/index.py +38 -0
  32. pyrig/dev/configs/markdown/readme.py +132 -0
  33. pyrig/dev/configs/py_typed.py +28 -0
  34. pyrig/dev/configs/pyproject.py +436 -0
  35. pyrig/dev/configs/python/__init__.py +5 -0
  36. pyrig/dev/configs/python/builders_init.py +27 -0
  37. pyrig/dev/configs/python/configs_init.py +28 -0
  38. pyrig/dev/configs/python/dot_experiment.py +46 -0
  39. pyrig/dev/configs/python/main.py +59 -0
  40. pyrig/dev/configs/python/resources_init.py +27 -0
  41. pyrig/dev/configs/python/shared_subcommands.py +29 -0
  42. pyrig/dev/configs/python/src_init.py +27 -0
  43. pyrig/dev/configs/python/subcommands.py +27 -0
  44. pyrig/dev/configs/testing/__init__.py +5 -0
  45. pyrig/dev/configs/testing/conftest.py +64 -0
  46. pyrig/dev/configs/testing/fixtures_init.py +27 -0
  47. pyrig/dev/configs/testing/main_test.py +74 -0
  48. pyrig/dev/configs/testing/zero_test.py +43 -0
  49. pyrig/dev/configs/workflows/__init__.py +5 -0
  50. pyrig/dev/configs/workflows/base/__init__.py +5 -0
  51. pyrig/dev/configs/workflows/base/base.py +1662 -0
  52. pyrig/dev/configs/workflows/build.py +106 -0
  53. pyrig/dev/configs/workflows/health_check.py +133 -0
  54. pyrig/dev/configs/workflows/publish.py +68 -0
  55. pyrig/dev/configs/workflows/release.py +90 -0
  56. pyrig/dev/tests/__init__.py +5 -0
  57. pyrig/dev/tests/conftest.py +40 -0
  58. pyrig/dev/tests/fixtures/__init__.py +1 -0
  59. pyrig/dev/tests/fixtures/assertions.py +147 -0
  60. pyrig/dev/tests/fixtures/autouse/__init__.py +5 -0
  61. pyrig/dev/tests/fixtures/autouse/class_.py +42 -0
  62. pyrig/dev/tests/fixtures/autouse/module.py +40 -0
  63. pyrig/dev/tests/fixtures/autouse/session.py +589 -0
  64. pyrig/dev/tests/fixtures/factories.py +118 -0
  65. pyrig/dev/utils/__init__.py +1 -0
  66. pyrig/dev/utils/cli.py +17 -0
  67. pyrig/dev/utils/git.py +312 -0
  68. pyrig/dev/utils/packages.py +93 -0
  69. pyrig/dev/utils/resources.py +77 -0
  70. pyrig/dev/utils/testing.py +66 -0
  71. pyrig/dev/utils/versions.py +268 -0
  72. pyrig/main.py +9 -0
  73. pyrig/py.typed +0 -0
  74. pyrig/resources/GITIGNORE +216 -0
  75. pyrig/resources/LATEST_PYTHON_VERSION +1 -0
  76. pyrig/resources/MIT_LICENSE_TEMPLATE +21 -0
  77. pyrig/resources/__init__.py +1 -0
  78. pyrig/src/__init__.py +1 -0
  79. pyrig/src/git/__init__.py +6 -0
  80. pyrig/src/git/git.py +146 -0
  81. pyrig/src/graph.py +255 -0
  82. pyrig/src/iterate.py +107 -0
  83. pyrig/src/modules/__init__.py +22 -0
  84. pyrig/src/modules/class_.py +369 -0
  85. pyrig/src/modules/function.py +189 -0
  86. pyrig/src/modules/inspection.py +148 -0
  87. pyrig/src/modules/module.py +658 -0
  88. pyrig/src/modules/package.py +452 -0
  89. pyrig/src/os/__init__.py +6 -0
  90. pyrig/src/os/os.py +121 -0
  91. pyrig/src/project/__init__.py +5 -0
  92. pyrig/src/project/mgt.py +83 -0
  93. pyrig/src/resource.py +58 -0
  94. pyrig/src/string.py +100 -0
  95. pyrig/src/testing/__init__.py +6 -0
  96. pyrig/src/testing/assertions.py +66 -0
  97. pyrig/src/testing/convention.py +203 -0
  98. pyrig-2.2.6.dist-info/METADATA +174 -0
  99. pyrig-2.2.6.dist-info/RECORD +102 -0
  100. pyrig-2.2.6.dist-info/WHEEL +4 -0
  101. pyrig-2.2.6.dist-info/entry_points.txt +3 -0
  102. pyrig-2.2.6.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,826 @@
1
+ """Abstract base classes for configuration file management.
2
+
3
+ This module provides the ConfigFile abstract base class and format-specific
4
+ subclasses for managing project configuration files. The system supports:
5
+
6
+ - Automatic discovery of ConfigFile subclasses across dependent packages
7
+ - Subset validation (configs can extend but not contradict base configs)
8
+ - Intelligent merging of missing configuration values
9
+ - Multiple file formats (YAML, TOML, Python, plain text)
10
+
11
+ The ConfigFile system is the heart of pyrig's automation. When you run
12
+ ``pyrig init`` or ``pyrig create-root``, all ConfigFile subclasses are
13
+ discovered and initialized, creating the complete project configuration.
14
+
15
+ Subclasses must implement:
16
+ - ``get_parent_path``: Directory containing the config file
17
+ - ``get_file_extension``: File extension (yaml, toml, py, etc.)
18
+ - ``get_configs``: Return the expected configuration structure
19
+ - ``load``: Load configuration from disk
20
+ - ``dump``: Write configuration to disk
21
+
22
+ Example:
23
+ class MyConfigFile(YamlConfigFile):
24
+ @classmethod
25
+ def get_parent_path(cls) -> Path:
26
+ return Path(".")
27
+
28
+ @classmethod
29
+ def get_configs(cls) -> dict[str, Any]:
30
+ return {"key": "value"}
31
+ """
32
+
33
+ import inspect
34
+ from abc import ABC, abstractmethod
35
+ from pathlib import Path
36
+ from types import ModuleType
37
+ from typing import Any
38
+
39
+ import tomlkit
40
+ import yaml
41
+
42
+ import pyrig
43
+ from pyrig.dev import configs
44
+ from pyrig.src.iterate import nested_structure_is_subset
45
+ from pyrig.src.modules.class_ import (
46
+ get_all_nonabst_subcls_from_mod_in_all_deps_depen_on_dep,
47
+ )
48
+ from pyrig.src.modules.module import (
49
+ get_isolated_obj_name,
50
+ get_module_content_as_str,
51
+ get_module_name_replacing_start_module,
52
+ make_pkg_dir,
53
+ to_path,
54
+ )
55
+ from pyrig.src.string import split_on_uppercase
56
+ from pyrig.src.testing.convention import TESTS_PACKAGE_NAME
57
+
58
+
59
+ class ConfigFile(ABC):
60
+ """Abstract base class for configuration file management.
61
+
62
+ Provides automatic creation, validation, and updating of configuration
63
+ files. Subclasses define the file format, location, and expected content.
64
+
65
+ The initialization process:
66
+ 1. Creates parent directories if needed
67
+ 2. Creates the file with default content if it doesn't exist
68
+ 3. Validates existing content against expected configuration
69
+ 4. Adds any missing configuration values
70
+
71
+ Subclasses must implement:
72
+ - ``get_parent_path``: Return the directory for the config file
73
+ - ``get_file_extension``: Return the file extension
74
+ - ``get_configs``: Return the expected configuration structure
75
+ - ``load``: Load and parse the configuration file
76
+ - ``dump``: Write configuration to the file
77
+ """
78
+
79
+ @classmethod
80
+ @abstractmethod
81
+ def get_parent_path(cls) -> Path:
82
+ """Get the directory containing the config file.
83
+
84
+ Returns:
85
+ Path to the parent directory.
86
+ """
87
+
88
+ @classmethod
89
+ @abstractmethod
90
+ def load(cls) -> dict[str, Any] | list[Any]:
91
+ """Load and parse the configuration file.
92
+
93
+ Returns:
94
+ The parsed configuration as a dict or list.
95
+ """
96
+
97
+ @classmethod
98
+ @abstractmethod
99
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
100
+ """Write configuration to the file.
101
+
102
+ Args:
103
+ config: The configuration to write.
104
+ """
105
+
106
+ @classmethod
107
+ @abstractmethod
108
+ def get_file_extension(cls) -> str:
109
+ """Get the file extension for this config file.
110
+
111
+ Returns:
112
+ The file extension without the leading dot.
113
+ """
114
+
115
+ @classmethod
116
+ @abstractmethod
117
+ def get_configs(cls) -> dict[str, Any] | list[Any]:
118
+ """Get the expected configuration structure.
119
+
120
+ Returns:
121
+ The configuration that should be present in the file.
122
+ """
123
+
124
+ def __init__(self) -> None:
125
+ """Initialize the config file, creating or updating as needed.
126
+
127
+ Raises:
128
+ ValueError: If the config file cannot be made correct.
129
+ """
130
+ self.get_path().parent.mkdir(parents=True, exist_ok=True)
131
+ if not self.get_path().exists():
132
+ self.get_path().touch()
133
+ self.dump(self.get_configs())
134
+
135
+ if not self.is_correct():
136
+ config = self.add_missing_configs()
137
+ self.dump(config)
138
+
139
+ if not self.is_correct():
140
+ msg = f"Config file {self.get_path()} is not correct."
141
+ raise ValueError(msg)
142
+
143
+ @classmethod
144
+ def get_path(cls) -> Path:
145
+ """Get the full path to the config file.
146
+
147
+ Returns:
148
+ Complete path including filename and extension.
149
+ """
150
+ return cls.get_parent_path() / (
151
+ cls.get_filename() + cls.get_extension_sep() + cls.get_file_extension()
152
+ )
153
+
154
+ @classmethod
155
+ def get_extension_sep(cls) -> str:
156
+ """Get the extension separator.
157
+
158
+ Returns:
159
+ The string ".".
160
+ """
161
+ return "."
162
+
163
+ @classmethod
164
+ def get_filename(cls) -> str:
165
+ """Derive the filename from the class name.
166
+
167
+ Removes abstract parent class suffixes and converts to snake_case.
168
+
169
+ Returns:
170
+ The filename without extension.
171
+ """
172
+ name = cls.__name__
173
+ abstract_parents = [
174
+ parent.__name__ for parent in cls.__mro__ if inspect.isabstract(parent)
175
+ ]
176
+ for parent in abstract_parents:
177
+ name = name.removesuffix(parent)
178
+ return "_".join(split_on_uppercase(name)).lower()
179
+
180
+ @classmethod
181
+ def add_missing_configs(cls) -> dict[str, Any] | list[Any]:
182
+ """Merge expected configuration into the current file.
183
+
184
+ Adds any missing keys or values from the expected configuration
185
+ to the current configuration without overwriting existing values.
186
+
187
+ Returns:
188
+ The merged configuration.
189
+ """
190
+ current_config = cls.load()
191
+ expected_config = cls.get_configs()
192
+ nested_structure_is_subset(
193
+ expected_config,
194
+ current_config,
195
+ cls.add_missing_dict_val,
196
+ cls.insert_missing_list_val,
197
+ )
198
+ return current_config
199
+
200
+ @staticmethod
201
+ def add_missing_dict_val(
202
+ expected_dict: dict[str, Any], actual_dict: dict[str, Any], key: str
203
+ ) -> None:
204
+ """Add a missing dictionary value during config merging.
205
+
206
+ Args:
207
+ expected_dict: The expected configuration dictionary.
208
+ actual_dict: The actual configuration dictionary to update.
209
+ key: The key to add or update.
210
+ """
211
+ expected_val = expected_dict[key]
212
+ actual_val = actual_dict.get(key)
213
+ actual_dict.setdefault(key, expected_val)
214
+
215
+ if isinstance(expected_val, dict) and isinstance(actual_val, dict):
216
+ actual_val.update(expected_val)
217
+ else:
218
+ actual_dict[key] = expected_val
219
+
220
+ @staticmethod
221
+ def insert_missing_list_val(
222
+ expected_list: list[Any], actual_list: list[Any], index: int
223
+ ) -> None:
224
+ """Insert a missing list value during config merging.
225
+
226
+ Args:
227
+ expected_list: The expected list.
228
+ actual_list: The actual list to update.
229
+ index: The index at which to insert.
230
+ """
231
+ actual_list.insert(index, expected_list[index])
232
+
233
+ @classmethod
234
+ def is_correct(cls) -> bool:
235
+ """Check if the configuration file is valid.
236
+
237
+ A file is considered correct if:
238
+ - It is empty (user opted out of this config)
239
+ - Its content is a superset of the expected configuration
240
+
241
+ Returns:
242
+ True if the configuration is valid.
243
+ """
244
+ return cls.is_unwanted() or cls.is_correct_recursively(
245
+ cls.get_configs(), cls.load()
246
+ )
247
+
248
+ @classmethod
249
+ def is_unwanted(cls) -> bool:
250
+ """Check if the user has opted out of this config file.
251
+
252
+ An empty file indicates the user doesn't want this configuration.
253
+
254
+ Returns:
255
+ True if the file exists and is empty.
256
+ """
257
+ return (
258
+ cls.get_path().exists() and cls.get_path().read_text(encoding="utf-8") == ""
259
+ )
260
+
261
+ @staticmethod
262
+ def is_correct_recursively(
263
+ expected_config: dict[str, Any] | list[Any],
264
+ actual_config: dict[str, Any] | list[Any],
265
+ ) -> bool:
266
+ """Recursively check if expected config is a subset of actual.
267
+
268
+ Args:
269
+ expected_config: The expected configuration structure.
270
+ actual_config: The actual configuration to validate.
271
+
272
+ Returns:
273
+ True if expected is a subset of actual.
274
+ """
275
+ return nested_structure_is_subset(expected_config, actual_config)
276
+
277
+ @classmethod
278
+ def get_all_subclasses(cls) -> list[type["ConfigFile"]]:
279
+ """Discover all non-abstract ConfigFile subclasses.
280
+
281
+ Searches all packages depending on pyrig for ConfigFile subclasses.
282
+
283
+ Returns:
284
+ List of ConfigFile subclass types.
285
+ """
286
+ return get_all_nonabst_subcls_from_mod_in_all_deps_depen_on_dep(
287
+ cls,
288
+ pyrig,
289
+ configs,
290
+ discard_parents=True,
291
+ )
292
+
293
+ @classmethod
294
+ def init_config_files(cls) -> None:
295
+ """Initialize all discovered ConfigFile subclasses.
296
+
297
+ Initializes files in order: priority files first, then ordered
298
+ files, then all remaining files.
299
+ """
300
+ cls.init_priority_config_files()
301
+ cls.init_ordered_config_files()
302
+
303
+ already_inited: set[type[ConfigFile]] = set(
304
+ cls.get_priority_config_files() + cls.get_ordered_config_files()
305
+ )
306
+
307
+ subclasses = cls.get_all_subclasses()
308
+ subclasses = [
309
+ subclass for subclass in subclasses if subclass not in already_inited
310
+ ]
311
+ for subclass in subclasses:
312
+ subclass()
313
+
314
+ @classmethod
315
+ def get_ordered_config_files(cls) -> list[type["ConfigFile"]]:
316
+ """Get config files that must be initialized in a specific order.
317
+
318
+ These files have dependencies on each other and must be
319
+ initialized after priority files but before general files.
320
+
321
+ Returns:
322
+ List of ConfigFile types in initialization order.
323
+ """
324
+ from pyrig.dev.configs.testing.conftest import ( # noqa: PLC0415
325
+ ConftestConfigFile,
326
+ )
327
+ from pyrig.dev.configs.testing.fixtures_init import ( # noqa: PLC0415
328
+ FixturesInitConfigFile,
329
+ )
330
+
331
+ return [
332
+ FixturesInitConfigFile,
333
+ ConftestConfigFile,
334
+ ]
335
+
336
+ @classmethod
337
+ def init_ordered_config_files(cls) -> None:
338
+ """Initialize config files that require specific ordering."""
339
+ for subclass in cls.get_ordered_config_files():
340
+ subclass()
341
+
342
+ @classmethod
343
+ def init_priority_config_files(cls) -> None:
344
+ """Initialize high-priority config files first."""
345
+ for subclass in cls.get_priority_config_files():
346
+ subclass()
347
+
348
+ @classmethod
349
+ def get_priority_config_files(cls) -> list[type["ConfigFile"]]:
350
+ """Get config files that must be initialized first.
351
+
352
+ These files are required by other config files or the build
353
+ process and must exist before other initialization can proceed.
354
+
355
+ Returns:
356
+ List of ConfigFile types in priority order.
357
+ """
358
+ # Some must be first:
359
+ from pyrig.dev.configs.git.gitignore import ( # noqa: PLC0415
360
+ GitIgnoreConfigFile,
361
+ )
362
+ from pyrig.dev.configs.licence import ( # noqa: PLC0415
363
+ LicenceConfigFile,
364
+ )
365
+ from pyrig.dev.configs.pyproject import ( # noqa: PLC0415
366
+ PyprojectConfigFile,
367
+ )
368
+ from pyrig.dev.configs.python.builders_init import ( # noqa: PLC0415
369
+ BuildersInitConfigFile,
370
+ )
371
+ from pyrig.dev.configs.python.configs_init import ( # noqa: PLC0415
372
+ ConfigsInitConfigFile,
373
+ )
374
+ from pyrig.dev.configs.python.main import ( # noqa: PLC0415
375
+ MainConfigFile,
376
+ )
377
+ from pyrig.dev.configs.testing.zero_test import ( # noqa: PLC0415
378
+ ZeroTestConfigFile,
379
+ )
380
+
381
+ return [
382
+ GitIgnoreConfigFile,
383
+ PyprojectConfigFile,
384
+ LicenceConfigFile,
385
+ MainConfigFile,
386
+ ConfigsInitConfigFile,
387
+ BuildersInitConfigFile,
388
+ ZeroTestConfigFile,
389
+ ]
390
+
391
+
392
+ class YamlConfigFile(ConfigFile):
393
+ """Abstract base class for YAML configuration files.
394
+
395
+ Provides YAML-specific load and dump implementations using PyYAML.
396
+ """
397
+
398
+ @classmethod
399
+ def load(cls) -> dict[str, Any] | list[Any]:
400
+ """Load and parse the YAML configuration file.
401
+
402
+ Returns:
403
+ The parsed YAML content as a dict or list.
404
+ """
405
+ return yaml.safe_load(cls.get_path().read_text(encoding="utf-8")) or {}
406
+
407
+ @classmethod
408
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
409
+ """Write configuration to the YAML file.
410
+
411
+ Args:
412
+ config: The configuration to write.
413
+ """
414
+ with cls.get_path().open("w") as f:
415
+ yaml.safe_dump(config, f, sort_keys=False)
416
+
417
+ @classmethod
418
+ def get_file_extension(cls) -> str:
419
+ """Get the YAML file extension.
420
+
421
+ Returns:
422
+ The string "yaml".
423
+ """
424
+ return "yaml"
425
+
426
+
427
+ class TomlConfigFile(ConfigFile):
428
+ """Abstract base class for TOML configuration files.
429
+
430
+ Provides TOML-specific load and dump implementations using tomlkit,
431
+ which preserves formatting and comments.
432
+ """
433
+
434
+ @classmethod
435
+ def load(cls) -> dict[str, Any]:
436
+ """Load and parse the TOML configuration file.
437
+
438
+ Returns:
439
+ The parsed TOML content as a dict.
440
+ """
441
+ return tomlkit.parse(cls.get_path().read_text(encoding="utf-8"))
442
+
443
+ @classmethod
444
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
445
+ """Write configuration to the TOML file.
446
+
447
+ Args:
448
+ config: The configuration dict to write.
449
+
450
+ Raises:
451
+ TypeError: If config is not a dict.
452
+ """
453
+ if not isinstance(config, dict):
454
+ msg = f"Cannot dump {config} to toml file."
455
+ raise TypeError(msg)
456
+ cls.pretty_dump(config)
457
+
458
+ @classmethod
459
+ def prettify_dict(cls, config: dict[str, Any]) -> dict[str, Any]:
460
+ """Convert a dict to a tomlkit table with multiline arrays.
461
+
462
+ Args:
463
+ config: The configuration dict to prettify.
464
+
465
+ Returns:
466
+ A tomlkit table with formatted arrays.
467
+ """
468
+ t = tomlkit.table()
469
+
470
+ for key, value in config.items():
471
+ if isinstance(value, list):
472
+ # Check if all items are dicts - use inline tables for those
473
+ if value and all(isinstance(item, dict) for item in value):
474
+ arr = tomlkit.array().multiline(multiline=True)
475
+ for item in value:
476
+ inline_table = tomlkit.inline_table()
477
+ inline_table.update(item)
478
+ arr.append(inline_table)
479
+ t.add(key, arr)
480
+ else:
481
+ # For non-dict items, use multiline arrays
482
+ arr = tomlkit.array().multiline(multiline=True)
483
+ for item in value:
484
+ arr.append(item)
485
+ t.add(key, arr)
486
+
487
+ elif isinstance(value, dict):
488
+ t.add(key, cls.prettify_dict(value))
489
+
490
+ else:
491
+ t.add(key, value)
492
+
493
+ return t
494
+
495
+ @classmethod
496
+ def pretty_dump(cls, config: dict[str, Any]) -> None:
497
+ """Write configuration to TOML with pretty formatting.
498
+
499
+ Converts lists to multiline arrays for readability.
500
+
501
+ Args:
502
+ config: The configuration dict to write.
503
+ """
504
+ # trun all lists into multiline arrays
505
+ config = cls.prettify_dict(config)
506
+ with cls.get_path().open("w") as f:
507
+ tomlkit.dump(config, f, sort_keys=False)
508
+
509
+ @classmethod
510
+ def get_file_extension(cls) -> str:
511
+ """Get the TOML file extension.
512
+
513
+ Returns:
514
+ The string "toml".
515
+ """
516
+ return "toml"
517
+
518
+
519
+ class TextConfigFile(ConfigFile):
520
+ """Abstract base class for plain text configuration files.
521
+
522
+ Suitable for files that have a required starting content but can
523
+ be extended by the user (e.g., Python files, README.md).
524
+
525
+ Attributes:
526
+ CONTENT_KEY: Dictionary key used to store file content.
527
+ """
528
+
529
+ CONTENT_KEY = "content"
530
+
531
+ @classmethod
532
+ @abstractmethod
533
+ def get_content_str(cls) -> str:
534
+ """Get the required content for this file.
535
+
536
+ Returns:
537
+ The content string that must be present in the file.
538
+ """
539
+
540
+ @classmethod
541
+ def load(cls) -> dict[str, str]:
542
+ """Load the text file content.
543
+
544
+ Returns:
545
+ Dict with the file content under CONTENT_KEY.
546
+ """
547
+ return {cls.CONTENT_KEY: cls.get_path().read_text(encoding="utf-8")}
548
+
549
+ @classmethod
550
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
551
+ """Write content to the text file.
552
+
553
+ Appends existing file content to preserve user additions.
554
+
555
+ Args:
556
+ config: Dict containing the content to write.
557
+
558
+ Raises:
559
+ TypeError: If config is not a dict.
560
+ """
561
+ if not isinstance(config, dict):
562
+ msg = f"Cannot dump {config} to text file."
563
+ raise TypeError(msg)
564
+ if cls.get_file_content().strip():
565
+ config[cls.CONTENT_KEY] = (
566
+ config[cls.CONTENT_KEY] + "\n" + cls.get_file_content()
567
+ )
568
+ cls.get_path().write_text(config[cls.CONTENT_KEY], encoding="utf-8")
569
+
570
+ @classmethod
571
+ def get_configs(cls) -> dict[str, Any]:
572
+ """Get the expected configuration structure.
573
+
574
+ Returns:
575
+ Dict with the required content under CONTENT_KEY.
576
+ """
577
+ return {cls.CONTENT_KEY: cls.get_content_str()}
578
+
579
+ @classmethod
580
+ def is_correct(cls) -> bool:
581
+ """Check if the text file contains the required content.
582
+
583
+ Returns:
584
+ True if the required content is present in the file.
585
+ """
586
+ return (
587
+ super().is_correct()
588
+ or cls.get_content_str().strip() in cls.load()[cls.CONTENT_KEY]
589
+ )
590
+
591
+ @classmethod
592
+ def get_file_content(cls) -> str:
593
+ """Get the current file content.
594
+
595
+ Returns:
596
+ The full content of the file.
597
+ """
598
+ return cls.load()[cls.CONTENT_KEY]
599
+
600
+
601
+ class MarkdownConfigFile(TextConfigFile):
602
+ """Abstract base class for Markdown configuration files.
603
+
604
+ Attributes:
605
+ CONTENT_KEY: Dictionary key used to store file content.
606
+ """
607
+
608
+ @classmethod
609
+ def get_file_extension(cls) -> str:
610
+ """Get the Markdown file extension.
611
+
612
+ Returns:
613
+ The string "md".
614
+ """
615
+ return "md"
616
+
617
+
618
+ class PythonConfigFile(TextConfigFile):
619
+ """Abstract base class for Python source file configuration.
620
+
621
+ Attributes:
622
+ CONTENT_KEY: Dictionary key used to store file content.
623
+ """
624
+
625
+ CONTENT_KEY = "content"
626
+
627
+ @classmethod
628
+ def get_file_extension(cls) -> str:
629
+ """Get the Python file extension.
630
+
631
+ Returns:
632
+ The string "py".
633
+ """
634
+ return "py"
635
+
636
+
637
+ class PythonPackageConfigFile(PythonConfigFile):
638
+ """Abstract base class for Python package configuration files.
639
+
640
+ Creates __init__.py files and ensures the parent directory is a
641
+ valid Python package.
642
+ """
643
+
644
+ @classmethod
645
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
646
+ """Write the config file and ensure parent is a package.
647
+
648
+ Args:
649
+ config: The configuration to write.
650
+ """
651
+ super().dump(config)
652
+ make_pkg_dir(cls.get_path().parent)
653
+
654
+
655
+ class CopyModuleConfigFile(PythonPackageConfigFile):
656
+ """Config file that copies content from an existing module.
657
+
658
+ Used to replicate pyrig's internal module structure in the target
659
+ project, allowing customization through subclassing.
660
+ """
661
+
662
+ @classmethod
663
+ @abstractmethod
664
+ def get_src_module(cls) -> ModuleType:
665
+ """Get the source module to copy.
666
+
667
+ Returns:
668
+ The module whose content will be copied.
669
+ """
670
+
671
+ @classmethod
672
+ def get_parent_path(cls) -> Path:
673
+ """Get the target directory for the copied module.
674
+
675
+ Transforms the source module path by replacing pyrig with
676
+ the target project's package name.
677
+
678
+ Returns:
679
+ Path to the target directory.
680
+ """
681
+ from pyrig.dev.configs.pyproject import PyprojectConfigFile # noqa: PLC0415
682
+
683
+ src_module = cls.get_src_module()
684
+ new_module_name = get_module_name_replacing_start_module(
685
+ src_module, PyprojectConfigFile.get_package_name()
686
+ )
687
+ return to_path(new_module_name, is_package=True).parent
688
+
689
+ @classmethod
690
+ def get_content_str(cls) -> str:
691
+ """Get the source module's content as a string.
692
+
693
+ Returns:
694
+ The full source code of the module.
695
+ """
696
+ src_module = cls.get_src_module()
697
+ return get_module_content_as_str(src_module)
698
+
699
+ @classmethod
700
+ def get_filename(cls) -> str:
701
+ """Get the filename from the source module name.
702
+
703
+ Returns:
704
+ The module's isolated name (without package prefix).
705
+ """
706
+ src_module = cls.get_src_module()
707
+ return get_isolated_obj_name(src_module)
708
+
709
+
710
+ class CopyModuleOnlyDocstringConfigFile(CopyModuleConfigFile):
711
+ """Config file that copies only the docstring from a module.
712
+
713
+ Useful for creating stub files that preserve documentation
714
+ but allow users to provide their own implementation.
715
+ """
716
+
717
+ @classmethod
718
+ def get_content_str(cls) -> str:
719
+ """Extract only the docstring from the source module.
720
+
721
+ Returns:
722
+ The module docstring wrapped in triple quotes.
723
+ """
724
+ content = super().get_content_str()
725
+ parts = content.split('"""', 2)
726
+ return '"""' + parts[1] + '"""\n'
727
+
728
+ @classmethod
729
+ def is_correct(cls) -> bool:
730
+ """Check if the file contains the source docstring.
731
+
732
+ Returns:
733
+ True if the docstring is present in the file.
734
+ """
735
+ docstring = cls.get_content_str().strip()
736
+ # remove the triple quotes from the docstring
737
+ docstring = docstring[3:-3]
738
+ return docstring in cls.get_file_content() or super().is_correct()
739
+
740
+
741
+ class InitConfigFile(CopyModuleOnlyDocstringConfigFile):
742
+ """Config file for creating __init__.py files.
743
+
744
+ Copies only the docstring from the source module's __init__.py.
745
+ """
746
+
747
+ @classmethod
748
+ def get_filename(cls) -> str:
749
+ """Get the __init__ filename.
750
+
751
+ Returns:
752
+ The string "__init__".
753
+ """
754
+ return "__init__"
755
+
756
+ @classmethod
757
+ def get_parent_path(cls) -> Path:
758
+ """Get the directory where __init__.py will be created.
759
+
760
+ Returns:
761
+ Path to the package directory.
762
+ """
763
+ path = super().get_parent_path()
764
+ # this path will be parent of the init file
765
+ return path / get_isolated_obj_name(cls.get_src_module())
766
+
767
+
768
+ class TypedConfigFile(ConfigFile):
769
+ """Config file for py.typed marker files.
770
+
771
+ Creates empty py.typed files to indicate PEP 561 compliance.
772
+ """
773
+
774
+ @classmethod
775
+ def get_file_extension(cls) -> str:
776
+ """Get the typed file extension.
777
+
778
+ Returns:
779
+ The string "typed".
780
+ """
781
+ return "typed"
782
+
783
+ @classmethod
784
+ def load(cls) -> dict[str, Any] | list[Any]:
785
+ """Load the py.typed file (always empty).
786
+
787
+ Returns:
788
+ An empty dict.
789
+ """
790
+ return {}
791
+
792
+ @classmethod
793
+ def dump(cls, config: dict[str, Any] | list[Any]) -> None:
794
+ """Validate that py.typed files remain empty.
795
+
796
+ Args:
797
+ config: Must be empty.
798
+
799
+ Raises:
800
+ ValueError: If config is not empty.
801
+ """
802
+ if config:
803
+ msg = "Cannot dump to py.typed file."
804
+ raise ValueError(msg)
805
+
806
+ @classmethod
807
+ def get_configs(cls) -> dict[str, Any] | list[Any]:
808
+ """Get the expected configuration (empty).
809
+
810
+ Returns:
811
+ An empty dict.
812
+ """
813
+ return {}
814
+
815
+
816
+ class PythonTestsConfigFile(PythonConfigFile):
817
+ """Abstract base class for Python files in the tests directory."""
818
+
819
+ @classmethod
820
+ def get_parent_path(cls) -> Path:
821
+ """Get the tests directory path.
822
+
823
+ Returns:
824
+ Path to the tests package.
825
+ """
826
+ return Path(TESTS_PACKAGE_NAME)