winipedia-utils 0.2.63__py3-none-any.whl → 0.6.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.

Potentially problematic release.


This version of winipedia-utils might be problematic. Click here for more details.

Files changed (51) hide show
  1. winipedia_utils/artifacts/build.py +78 -0
  2. winipedia_utils/concurrent/concurrent.py +7 -2
  3. winipedia_utils/concurrent/multiprocessing.py +1 -2
  4. winipedia_utils/concurrent/multithreading.py +2 -2
  5. winipedia_utils/data/dataframe/cleaning.py +337 -100
  6. winipedia_utils/git/github/__init__.py +1 -0
  7. winipedia_utils/git/github/github.py +31 -0
  8. winipedia_utils/git/github/repo/__init__.py +1 -0
  9. winipedia_utils/git/github/repo/protect.py +103 -0
  10. winipedia_utils/git/github/repo/repo.py +205 -0
  11. winipedia_utils/git/github/workflows/base/__init__.py +1 -0
  12. winipedia_utils/git/github/workflows/base/base.py +889 -0
  13. winipedia_utils/git/github/workflows/health_check.py +69 -0
  14. winipedia_utils/git/github/workflows/publish.py +51 -0
  15. winipedia_utils/git/github/workflows/release.py +90 -0
  16. winipedia_utils/git/gitignore/config.py +77 -0
  17. winipedia_utils/git/gitignore/gitignore.py +5 -63
  18. winipedia_utils/git/pre_commit/config.py +49 -59
  19. winipedia_utils/git/pre_commit/hooks.py +46 -46
  20. winipedia_utils/git/pre_commit/run_hooks.py +19 -12
  21. winipedia_utils/iterating/iterate.py +63 -1
  22. winipedia_utils/modules/class_.py +69 -12
  23. winipedia_utils/modules/function.py +26 -3
  24. winipedia_utils/modules/inspection.py +56 -0
  25. winipedia_utils/modules/module.py +22 -28
  26. winipedia_utils/modules/package.py +116 -10
  27. winipedia_utils/projects/poetry/config.py +255 -112
  28. winipedia_utils/projects/poetry/poetry.py +230 -13
  29. winipedia_utils/projects/project.py +11 -42
  30. winipedia_utils/setup.py +11 -29
  31. winipedia_utils/testing/config.py +127 -0
  32. winipedia_utils/testing/create_tests.py +5 -19
  33. winipedia_utils/testing/skip.py +19 -0
  34. winipedia_utils/testing/tests/base/fixtures/fixture.py +36 -0
  35. winipedia_utils/testing/tests/base/fixtures/scopes/class_.py +3 -3
  36. winipedia_utils/testing/tests/base/fixtures/scopes/module.py +9 -6
  37. winipedia_utils/testing/tests/base/fixtures/scopes/session.py +27 -176
  38. winipedia_utils/testing/tests/base/utils/utils.py +27 -57
  39. winipedia_utils/text/config.py +250 -0
  40. winipedia_utils/text/string.py +30 -0
  41. winipedia_utils-0.6.6.dist-info/METADATA +390 -0
  42. {winipedia_utils-0.2.63.dist-info → winipedia_utils-0.6.6.dist-info}/RECORD +46 -34
  43. winipedia_utils/consts.py +0 -21
  44. winipedia_utils/git/workflows/base/base.py +0 -77
  45. winipedia_utils/git/workflows/publish.py +0 -79
  46. winipedia_utils/git/workflows/release.py +0 -91
  47. winipedia_utils-0.2.63.dist-info/METADATA +0 -738
  48. /winipedia_utils/{git/workflows/base → artifacts}/__init__.py +0 -0
  49. /winipedia_utils/git/{workflows → github/workflows}/__init__.py +0 -0
  50. {winipedia_utils-0.2.63.dist-info → winipedia_utils-0.6.6.dist-info}/WHEEL +0 -0
  51. {winipedia_utils-0.2.63.dist-info → winipedia_utils-0.6.6.dist-info}/licenses/LICENSE +0 -0
@@ -15,11 +15,10 @@ from winipedia_utils.os.os import run_subprocess
15
15
  logger = get_logger(__name__)
16
16
 
17
17
 
18
- def _run_all_hooks() -> None:
18
+ def run_hooks() -> None:
19
19
  """Import all funcs defined in hooks.py and runs them."""
20
20
  hook_funcs = get_all_functions_from_module(hooks)
21
21
 
22
- exit_code = 0
23
22
  for hook_func in hook_funcs:
24
23
  subprocess_args = hook_func()
25
24
  result = run_subprocess(
@@ -28,22 +27,30 @@ def _run_all_hooks() -> None:
28
27
  passed = result.returncode == 0
29
28
 
30
29
  log_method = logger.info
31
- passed_str = (f"{GREEN}PASSED" if passed else f"{RED}FAILED") + RESET
30
+ status_str = (f"{GREEN}PASSED" if passed else f"{RED}FAILED") + RESET
32
31
  if not passed:
33
32
  log_method = logger.error
34
- passed_str += f"\n{result.stdout}"
35
- exit_code = 1
33
+ status_str += f"""
34
+ ---------------------------------------------------------------------------------------------
35
+ Stdout:
36
+
37
+ {result.stdout}
38
+
39
+ ---------------------------------------------------------------------------------------------
40
+ Stderr:
41
+
42
+ {result.stderr}
43
+
44
+ ---------------------------------------------------------------------------------------------
45
+ """
46
+
36
47
  # make the dashes always the same lentgth by adjusting to len of hook name
37
48
  num_dashes = 50 - len(hook_func.__name__)
38
49
  log_method(
39
50
  "Hook %s -%s> %s",
40
51
  hook_func.__name__,
41
52
  "-" * num_dashes,
42
- passed_str,
53
+ status_str,
43
54
  )
44
-
45
- sys.exit(exit_code)
46
-
47
-
48
- if __name__ == "__main__":
49
- _run_all_hooks()
55
+ if not passed:
56
+ sys.exit(1)
@@ -5,7 +5,7 @@ including getting the length of an iterable with a default value.
5
5
  These utilities help with iterable operations and manipulations.
6
6
  """
7
7
 
8
- from collections.abc import Iterable
8
+ from collections.abc import Callable, Iterable
9
9
  from typing import Any
10
10
 
11
11
 
@@ -27,3 +27,65 @@ def get_len_with_default(iterable: Iterable[Any], default: int | None = None) ->
27
27
  msg = "Can't get length of iterable and no default value provided"
28
28
  raise TypeError(msg) from e
29
29
  return default
30
+
31
+
32
+ def nested_structure_is_subset(
33
+ subset: dict[Any, Any] | list[Any] | Any,
34
+ superset: dict[Any, Any] | list[Any] | Any,
35
+ on_false_dict_action: Callable[[dict[Any, Any], dict[Any, Any], Any], Any]
36
+ | None = None,
37
+ on_false_list_action: Callable[[list[Any], list[Any], int], Any] | None = None,
38
+ ) -> bool:
39
+ """Check if a dictionary is a nested subset of another dictionary.
40
+
41
+ Args:
42
+ subset: Dictionary to check
43
+ superset: Dictionary to check against
44
+ on_false_dict_action: Action to take on each false dict comparison
45
+ must return a bool to indicate if after action is still false
46
+ on_false_list_action: Action to take on each false list comparison
47
+ must return a bool to indicate if after action is still false
48
+
49
+ Each value of a key must be equal to the value of the same key in the superset.
50
+ If the value is dictionary, the function is called recursively.
51
+ If the value is list, each item must be in the list of the same key in the superset.
52
+ The order in lists does not matter.
53
+
54
+ Returns:
55
+ True if subset is a nested subset of superset, False otherwise
56
+ """
57
+ if isinstance(subset, dict) and isinstance(superset, dict):
58
+ iterable: Iterable[tuple[Any, Any]] = subset.items()
59
+ on_false_action: Callable[[Any, Any, Any], Any] | None = on_false_dict_action
60
+
61
+ def get_actual(key_or_index: Any) -> Any:
62
+ """Get actual value from superset."""
63
+ return superset.get(key_or_index)
64
+
65
+ elif isinstance(subset, list) and isinstance(superset, list):
66
+ iterable = enumerate(subset)
67
+ on_false_action = on_false_list_action
68
+
69
+ def get_actual(key_or_index: Any) -> Any:
70
+ """Get actual value from superset."""
71
+ subset_val = subset[key_or_index]
72
+ for superset_val in superset:
73
+ if nested_structure_is_subset(subset_val, superset_val):
74
+ return superset_val
75
+
76
+ return superset[key_or_index] if key_or_index < len(superset) else None
77
+ else:
78
+ return subset == superset
79
+
80
+ all_good = True
81
+ for key_or_index, value in iterable:
82
+ actual_value = get_actual(key_or_index)
83
+ if not nested_structure_is_subset(
84
+ value, actual_value, on_false_dict_action, on_false_list_action
85
+ ):
86
+ all_good = False
87
+ if on_false_action is not None:
88
+ on_false_action(subset, superset, key_or_index)
89
+ all_good = nested_structure_is_subset(subset, superset)
90
+
91
+ return all_good
@@ -13,10 +13,14 @@ from types import ModuleType
13
13
  from typing import Any
14
14
 
15
15
  from winipedia_utils.modules.function import is_func
16
+ from winipedia_utils.modules.inspection import get_def_line, get_obj_members
16
17
 
17
18
 
18
19
  def get_all_methods_from_cls(
19
- class_: type, *, exclude_parent_methods: bool = False
20
+ class_: type,
21
+ *,
22
+ exclude_parent_methods: bool = False,
23
+ include_annotate: bool = False,
20
24
  ) -> list[Callable[..., Any]]:
21
25
  """Get all methods from a class.
22
26
 
@@ -27,14 +31,21 @@ def get_all_methods_from_cls(
27
31
  class_: The class to extract methods from
28
32
  exclude_parent_methods: If True, only include methods defined in this class,
29
33
  excluding those inherited from parent classes
34
+ include_annotate: If False, exclude __annotate__ method
35
+ introduced in Python 3.14, defaults to False
36
+
30
37
  Returns:
31
38
  A list of callable methods from the class
32
39
 
33
40
  """
34
- from winipedia_utils.modules.module import get_def_line, get_module_of_obj
41
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
42
+ get_module_of_obj,
43
+ )
35
44
 
36
45
  methods = [
37
- (method, name) for name, method in inspect.getmembers(class_) if is_func(method)
46
+ (method, name)
47
+ for name, method in get_obj_members(class_, include_annotate=include_annotate)
48
+ if is_func(method)
38
49
  ]
39
50
 
40
51
  if exclude_parent_methods:
@@ -63,7 +74,9 @@ def get_all_cls_from_module(module: ModuleType | str) -> list[type]:
63
74
  A list of class types defined in the module
64
75
 
65
76
  """
66
- from winipedia_utils.modules.module import get_def_line, get_module_of_obj
77
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
78
+ get_module_of_obj,
79
+ )
67
80
 
68
81
  if isinstance(module, str):
69
82
  module = import_module(module)
@@ -79,7 +92,9 @@ def get_all_cls_from_module(module: ModuleType | str) -> list[type]:
79
92
  return sorted(classes, key=get_def_line)
80
93
 
81
94
 
82
- def get_all_subclasses(cls: type) -> list[type]:
95
+ def get_all_subclasses(
96
+ cls: type, load_package_before: ModuleType | None = None
97
+ ) -> set[type]:
83
98
  """Get all subclasses of a class.
84
99
 
85
100
  Retrieves all classes that are subclasses of the specified class.
@@ -87,18 +102,37 @@ def get_all_subclasses(cls: type) -> list[type]:
87
102
 
88
103
  Args:
89
104
  cls: The class to find subclasses of
105
+ load_package_before: If provided,
106
+ walks the package before loading the subclasses
107
+ This is useful when the subclasses are defined in other modules that need
108
+ to be imported before they can be found by __subclasses__
90
109
 
91
110
  Returns:
92
111
  A list of subclasses of the given class
93
112
 
94
113
  """
114
+ from winipedia_utils.modules.package import ( # noqa: PLC0415 # avoid circular import
115
+ walk_package,
116
+ )
117
+
118
+ if load_package_before:
119
+ _ = list(walk_package(load_package_before))
95
120
  subclasses_set = set(cls.__subclasses__())
96
121
  for subclass in cls.__subclasses__():
97
122
  subclasses_set.update(get_all_subclasses(subclass))
98
- return list(subclasses_set)
99
-
100
-
101
- def get_all_nonabstract_subclasses(cls: type) -> list[type]:
123
+ if load_package_before is not None:
124
+ # remove all not in the package
125
+ subclasses_set = {
126
+ subclass
127
+ for subclass in subclasses_set
128
+ if subclass.__module__.startswith(load_package_before.__name__)
129
+ }
130
+ return subclasses_set
131
+
132
+
133
+ def get_all_nonabstract_subclasses(
134
+ cls: type, load_package_before: ModuleType | None = None
135
+ ) -> set[type]:
102
136
  """Get all non-abstract subclasses of a class.
103
137
 
104
138
  Retrieves all classes that are subclasses of the specified class,
@@ -107,13 +141,36 @@ def get_all_nonabstract_subclasses(cls: type) -> list[type]:
107
141
 
108
142
  Args:
109
143
  cls: The class to find subclasses of
144
+ load_package_before: If provided,
145
+ walks the package before loading the subclasses
146
+ This is useful when the subclasses are defined in other modules that need
147
+ to be imported before they can be found by __subclasses__
110
148
 
111
149
  Returns:
112
150
  A list of non-abstract subclasses of the given class
113
151
 
114
152
  """
115
- return [
153
+ return {
116
154
  subclass
117
- for subclass in get_all_subclasses(cls)
155
+ for subclass in get_all_subclasses(cls, load_package_before=load_package_before)
118
156
  if not inspect.isabstract(subclass)
119
- ]
157
+ }
158
+
159
+
160
+ def init_all_nonabstract_subclasses(
161
+ cls: type, load_package_before: ModuleType | None = None
162
+ ) -> None:
163
+ """Initialize all non-abstract subclasses of a class.
164
+
165
+ Args:
166
+ cls: The class to find subclasses of
167
+ load_package_before: If provided,
168
+ walks the package before loading the subclasses
169
+ This is useful when the subclasses are defined in other modules that need
170
+ to be imported before they can be found by __subclasses__
171
+
172
+ """
173
+ for subclass in get_all_nonabstract_subclasses(
174
+ cls, load_package_before=load_package_before
175
+ ):
176
+ subclass()
@@ -12,6 +12,8 @@ from importlib import import_module
12
12
  from types import ModuleType
13
13
  from typing import Any
14
14
 
15
+ from winipedia_utils.modules.inspection import get_def_line, get_obj_members
16
+
15
17
 
16
18
  def is_func_or_method(obj: Any) -> bool:
17
19
  """Return True if *obj* is a function or method.
@@ -57,7 +59,9 @@ def is_func(obj: Any) -> bool:
57
59
  return is_func_or_method(unwrapped)
58
60
 
59
61
 
60
- def get_all_functions_from_module(module: ModuleType | str) -> list[Callable[..., Any]]:
62
+ def get_all_functions_from_module(
63
+ module: ModuleType | str, *, include_annotate: bool = False
64
+ ) -> list[Callable[..., Any]]:
61
65
  """Get all functions defined in a module.
62
66
 
63
67
  Retrieves all function objects that are defined directly in the specified module,
@@ -66,18 +70,23 @@ def get_all_functions_from_module(module: ModuleType | str) -> list[Callable[...
66
70
 
67
71
  Args:
68
72
  module: The module to extract functions from
73
+ include_annotate: If False, exclude __annotate__ method
74
+ introduced in Python 3.14, defaults to False
69
75
 
70
76
  Returns:
71
77
  A list of callable functions defined in the module
72
78
 
73
79
  """
74
- from winipedia_utils.modules.module import get_def_line, get_module_of_obj
80
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
81
+ get_module_of_obj,
82
+ )
75
83
 
76
84
  if isinstance(module, str):
77
85
  module = import_module(module)
78
86
  funcs = [
79
87
  func
80
- for _name, func in inspect.getmembers(module, is_func)
88
+ for _name, func in get_obj_members(module, include_annotate=include_annotate)
89
+ if is_func(func)
81
90
  if get_module_of_obj(func).__name__ == module.__name__
82
91
  ]
83
92
  # sort by definition order
@@ -99,3 +108,17 @@ def unwrap_method(method: Any) -> Callable[..., Any] | Any:
99
108
  if isinstance(method, property):
100
109
  method = method.fget
101
110
  return inspect.unwrap(method)
111
+
112
+
113
+ def is_abstractmethod(method: Any) -> bool:
114
+ """Check if a method is an abstract method.
115
+
116
+ Args:
117
+ method: The method to check
118
+
119
+ Returns:
120
+ True if the method is an abstract method, False otherwise
121
+
122
+ """
123
+ method = unwrap_method(method)
124
+ return getattr(method, "__isabstractmethod__", False)
@@ -0,0 +1,56 @@
1
+ """Inspection utilities for introspecting Python objects.
2
+
3
+ This module provides utility functions for inspecting Python objects,
4
+ including checking if an object is a function or method, and unwrapping
5
+ methods to their underlying functions.
6
+ """
7
+
8
+ import inspect
9
+ import sys
10
+ from collections.abc import Callable
11
+ from typing import Any, cast
12
+
13
+
14
+ def get_obj_members(
15
+ obj: Any, *, include_annotate: bool = False
16
+ ) -> list[tuple[str, Any]]:
17
+ """Get all members of an object."""
18
+ members = [(member, value) for member, value in inspect.getmembers(obj)]
19
+ if not include_annotate:
20
+ members = [
21
+ (member, value)
22
+ for member, value in members
23
+ if member not in ("__annotate__", "__annotate_func__")
24
+ ]
25
+ return members
26
+
27
+
28
+ def inside_frozen_bundle() -> bool:
29
+ """Return True if the code is running inside a frozen bundle."""
30
+ return getattr(sys, "frozen", False)
31
+
32
+
33
+ def get_def_line(obj: Any) -> int:
34
+ """Return the line number where a method-like object is defined."""
35
+ if isinstance(obj, property):
36
+ obj = obj.fget
37
+ unwrapped = inspect.unwrap(obj)
38
+ if hasattr(unwrapped, "__code__"):
39
+ return int(unwrapped.__code__.co_firstlineno)
40
+ # getsourcelines does not work if in a pyinstaller bundle or something
41
+ if inside_frozen_bundle():
42
+ return 0
43
+ return inspect.getsourcelines(unwrapped)[1]
44
+
45
+
46
+ def get_unwrapped_obj(obj: Any) -> Any:
47
+ """Return the unwrapped version of a method-like object."""
48
+ if isinstance(obj, property):
49
+ obj = obj.fget # get the getter function of the property
50
+ return inspect.unwrap(obj)
51
+
52
+
53
+ def get_qualname_of_obj(obj: Callable[..., Any] | type) -> str:
54
+ """Return the name of a method-like object."""
55
+ unwrapped = get_unwrapped_obj(obj)
56
+ return cast("str", unwrapped.__qualname__)
@@ -17,7 +17,7 @@ from collections.abc import Callable, Sequence
17
17
  from importlib import import_module
18
18
  from pathlib import Path
19
19
  from types import ModuleType
20
- from typing import Any, cast
20
+ from typing import Any
21
21
 
22
22
  from winipedia_utils.logging.logger import get_logger
23
23
  from winipedia_utils.modules.class_ import (
@@ -25,6 +25,7 @@ from winipedia_utils.modules.class_ import (
25
25
  get_all_methods_from_cls,
26
26
  )
27
27
  from winipedia_utils.modules.function import get_all_functions_from_module
28
+ from winipedia_utils.modules.inspection import get_qualname_of_obj, get_unwrapped_obj
28
29
  from winipedia_utils.modules.package import (
29
30
  get_modules_and_packages_from_package,
30
31
  make_dir_with_init_file,
@@ -215,6 +216,8 @@ def import_obj_from_importpath(
215
216
  return import_module(importpath)
216
217
  except ImportError:
217
218
  # might be a class or function
219
+ if "." not in importpath:
220
+ raise
218
221
  module_name, obj_name = importpath.rsplit(".", 1)
219
222
  module = import_module(module_name)
220
223
  obj: Callable[..., Any] | type | ModuleType = getattr(module, obj_name)
@@ -327,24 +330,6 @@ def get_default_module_content() -> str:
327
330
  return '''"""module."""'''
328
331
 
329
332
 
330
- def inside_frozen_bundle() -> bool:
331
- """Return True if the code is running inside a frozen bundle."""
332
- return getattr(sys, "frozen", False)
333
-
334
-
335
- def get_def_line(obj: Any) -> int:
336
- """Return the line number where a method-like object is defined."""
337
- if isinstance(obj, property):
338
- obj = obj.fget
339
- unwrapped = inspect.unwrap(obj)
340
- if hasattr(unwrapped, "__code__"):
341
- return int(unwrapped.__code__.co_firstlineno)
342
- # getsourcelines does not work if in a pyinstaller bundle or something
343
- if inside_frozen_bundle():
344
- return 0
345
- return inspect.getsourcelines(unwrapped)[1]
346
-
347
-
348
333
  def get_module_of_obj(obj: Any, default: ModuleType | None = None) -> ModuleType:
349
334
  """Return the module name where a method-like object is defined.
350
335
 
@@ -366,14 +351,23 @@ def get_module_of_obj(obj: Any, default: ModuleType | None = None) -> ModuleType
366
351
  return module
367
352
 
368
353
 
369
- def get_qualname_of_obj(obj: Callable[..., Any] | type) -> str:
370
- """Return the name of a method-like object."""
371
- unwrapped = get_unwrapped_obj(obj)
372
- return cast("str", unwrapped.__qualname__)
354
+ def get_executing_module() -> ModuleType:
355
+ """Get the module where execution has started.
373
356
 
357
+ The executing module is the module that contains the __main__ attribute as __name__
358
+ E.g. if you run `python -m winipedia_utils.setup` from the command line,
359
+ then the executing module is winipedia_utils.modules.setup
374
360
 
375
- def get_unwrapped_obj(obj: Any) -> Any:
376
- """Return the unwrapped version of a method-like object."""
377
- if isinstance(obj, property):
378
- obj = obj.fget # get the getter function of the property
379
- return inspect.unwrap(obj)
361
+ Returns:
362
+ The module where execution has started
363
+
364
+ Raises:
365
+ ValueError: If no __main__ module is found or if the executing module
366
+ cannot be determined
367
+
368
+ """
369
+ main = sys.modules.get("__main__")
370
+ if main is None:
371
+ msg = "No __main__ module found"
372
+ raise ValueError(msg)
373
+ return main
@@ -9,21 +9,23 @@ The utilities support both static package analysis and dynamic package manipulat
9
9
  making them suitable for code generation, testing frameworks, and package management.
10
10
  """
11
11
 
12
+ import importlib.metadata
13
+ import importlib.util
12
14
  import os
13
15
  import pkgutil
16
+ import re
14
17
  import sys
15
18
  from collections.abc import Generator, Iterable
16
19
  from importlib import import_module
17
20
  from pathlib import Path
18
21
  from types import ModuleType
22
+ from typing import Any
19
23
 
24
+ import networkx as nx
20
25
  from setuptools import find_namespace_packages as _find_namespace_packages
21
26
  from setuptools import find_packages as _find_packages
22
27
 
23
- from winipedia_utils.git.gitignore.gitignore import (
24
- load_gitignore,
25
- walk_os_skipping_gitignore_patterns,
26
- )
28
+ import winipedia_utils
27
29
  from winipedia_utils.logging.logger import get_logger
28
30
 
29
31
  logger = get_logger(__name__)
@@ -44,7 +46,9 @@ def get_src_package() -> ModuleType:
44
46
  if only the test package exists
45
47
 
46
48
  """
47
- from winipedia_utils.testing.convention import TESTS_PACKAGE_NAME
49
+ from winipedia_utils.testing.convention import ( # noqa: PLC0415 # avoid circular import
50
+ TESTS_PACKAGE_NAME,
51
+ )
48
52
 
49
53
  packages = find_packages_as_modules(depth=0)
50
54
  return next(p for p in packages if p.__name__ != TESTS_PACKAGE_NAME)
@@ -180,8 +184,13 @@ def find_packages(
180
184
  find_packages(depth=1) might return ["package1", "package2"]
181
185
 
182
186
  """
187
+ from winipedia_utils.git.gitignore.config import ( # noqa: PLC0415
188
+ GitIgnoreConfigFile, # avoid circular import
189
+ )
190
+
183
191
  if exclude is None:
184
- exclude = load_gitignore()
192
+ # must init GitIgnoreConfigFile to create .gitignore if it does not exist
193
+ exclude = GitIgnoreConfigFile.load()
185
194
  exclude = [
186
195
  p.replace("/", ".").removesuffix(".") for p in exclude if p.endswith("/")
187
196
  ]
@@ -280,7 +289,12 @@ def make_init_modules_for_package(path: str | Path | ModuleType) -> None:
280
289
  from get_default_init_module_content.
281
290
 
282
291
  """
283
- from winipedia_utils.modules.module import to_path
292
+ from winipedia_utils.git.gitignore.gitignore import ( # noqa: PLC0415
293
+ walk_os_skipping_gitignore_patterns, # avoid circular import
294
+ )
295
+ from winipedia_utils.modules.module import ( # noqa: PLC0415
296
+ to_path, # avoid circular import
297
+ )
284
298
 
285
299
  path = to_path(path, is_package=True)
286
300
 
@@ -305,7 +319,10 @@ def make_init_module(path: str | Path) -> None:
305
319
  Creates parent directories if they don't exist.
306
320
 
307
321
  """
308
- from winipedia_utils.modules.module import get_default_init_module_content, to_path
322
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
323
+ get_default_init_module_content,
324
+ to_path,
325
+ )
309
326
 
310
327
  path = to_path(path, is_package=True)
311
328
 
@@ -338,7 +355,7 @@ def copy_package(
338
355
  with_file_content (bool, optional): copies the content of the files.
339
356
 
340
357
  """
341
- from winipedia_utils.modules.module import (
358
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
342
359
  create_module,
343
360
  get_isolated_obj_name,
344
361
  get_module_content_as_str,
@@ -368,7 +385,9 @@ def get_main_package() -> ModuleType:
368
385
 
369
386
  Even when this package is installed as a module.
370
387
  """
371
- from winipedia_utils.modules.module import to_module_name
388
+ from winipedia_utils.modules.module import ( # noqa: PLC0415 # avoid circular import
389
+ to_module_name,
390
+ )
372
391
 
373
392
  main = sys.modules.get("__main__")
374
393
  if main is None:
@@ -388,3 +407,90 @@ def get_main_package() -> ModuleType:
388
407
 
389
408
  msg = "Not able to determine the main package"
390
409
  raise ValueError(msg)
410
+
411
+
412
+ class DependencyGraph(nx.DiGraph): # type: ignore [type-arg]
413
+ """A directed graph representing Python package dependencies."""
414
+
415
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
416
+ """Initialize the dependency graph and build it immediately."""
417
+ super().__init__(*args, **kwargs)
418
+ self.build()
419
+
420
+ def build(self) -> None:
421
+ """Build the graph from installed Python distributions."""
422
+ for dist in importlib.metadata.distributions():
423
+ name = self.parse_distname_from_metadata(dist)
424
+ self.add_node(name)
425
+
426
+ requires = dist.requires or []
427
+ for req in requires:
428
+ dep = self.parse_pkg_name_from_req(req)
429
+ if dep:
430
+ self.add_edge(name, dep) # package → dependency
431
+
432
+ @staticmethod
433
+ def parse_distname_from_metadata(dist: importlib.metadata.Distribution) -> str:
434
+ """Extract the distribution name from its metadata."""
435
+ # replace - with _ to handle packages like winipedia-utils
436
+ name: str = dist.metadata["Name"]
437
+ return DependencyGraph.normalize_package_name(name)
438
+
439
+ @staticmethod
440
+ def normalize_package_name(name: str) -> str:
441
+ """Normalize a package name."""
442
+ return name.lower().replace("-", "_").strip()
443
+
444
+ @staticmethod
445
+ def parse_pkg_name_from_req(req: str) -> str | None:
446
+ """Extract the bare dependency name from a requirement string."""
447
+ # split on the first non alphanumeric character like >, <, =, etc.
448
+ # keep - and _ for names like winipedia-utils or winipedia_utils
449
+ dep = re.split(r"[^a-zA-Z0-9_-]", req.strip())[0].strip()
450
+ return DependencyGraph.normalize_package_name(dep) if dep else None
451
+
452
+ def get_all_depending_on(
453
+ self, package: ModuleType, *, include_self: bool = False
454
+ ) -> set[ModuleType]:
455
+ """Return all packages that directly or indirectly depend on the given package.
456
+
457
+ Args:
458
+ package: The module whose dependents should be found.
459
+ include_self: Whether to include the package itself in the result.
460
+
461
+ Returns:
462
+ A set of imported module objects representing dependents.
463
+ """
464
+ # replace - with _ to handle packages like winipedia-utils
465
+ target = package.__name__.lower()
466
+ if target not in self:
467
+ msg = f"Package '{target}' not found in dependency graph"
468
+ raise ValueError(msg)
469
+
470
+ dependents = nx.ancestors(self, target)
471
+ if include_self:
472
+ dependents.add(target)
473
+
474
+ return self.import_packages(dependents)
475
+
476
+ @staticmethod
477
+ def import_packages(names: set[str]) -> set[ModuleType]:
478
+ """Attempt to import all module names that can be resolved."""
479
+ modules: set[ModuleType] = set()
480
+ for name in names:
481
+ spec = importlib.util.find_spec(name)
482
+ if spec is not None:
483
+ modules.add(importlib.import_module(name))
484
+ return modules
485
+
486
+ def get_all_depending_on_winipedia_utils(
487
+ self, *, include_winipedia_utils: bool = False
488
+ ) -> set[ModuleType]:
489
+ """Return all packages that directly or indirectly depend on winipedia_utils."""
490
+ if get_src_package() == winipedia_utils:
491
+ deps: set[ModuleType] = set()
492
+ else:
493
+ deps = self.get_all_depending_on(winipedia_utils, include_self=False)
494
+ if include_winipedia_utils:
495
+ deps.add(winipedia_utils)
496
+ return deps