├── .copier-answers.yml
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .gitpod.dockerfile
├── .gitpod.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CREDITS.md
├── LICENSE
├── Makefile
├── README.md
├── config
├── archan.yml
├── black.toml
├── coverage.ini
├── mypy.ini
├── pytest.ini
└── ruff.toml
├── demo.script
├── demo.svg
├── docs
├── .overrides
│ └── main.html
├── changelog.md
├── code_of_conduct.md
├── contributing.md
├── credits.md
├── css
│ ├── material.css
│ └── mkdocstrings.css
├── demo.svg
├── index.md
├── license.md
└── usage.md
├── duties.py
├── mkdocs.insiders.yml
├── mkdocs.yml
├── pyproject.toml
├── scripts
├── gen_credits.py
├── gen_ref_nav.py
└── setup.sh
├── src
└── dependenpy
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── dsm.py
│ ├── finder.py
│ ├── helpers.py
│ ├── node.py
│ ├── plugins.py
│ ├── py.typed
│ └── structures.py
└── tests
├── __init__.py
├── conftest.py
├── fixtures
├── .gitkeep
├── external
│ ├── __init__.py
│ └── module_a.py
└── internal
│ ├── __init__.py
│ ├── module_a.py
│ └── subpackage_a
│ ├── __init__.py
│ ├── module_1.py
│ └── subpackage_1
│ ├── __init__.py
│ └── module_i.py
├── test_cli.py
└── test_dependenpy.py
/.copier-answers.yml:
--------------------------------------------------------------------------------
1 | # Changes here will be overwritten by Copier
2 | _commit: 0.16.9
3 | _src_path: gh:pawamoy/copier-pdm.git
4 | author_email: pawamoy@pm.me
5 | author_fullname: Timothée Mazzucotelli
6 | author_username: pawamoy
7 | copyright_date: '2020'
8 | copyright_holder: Timothée Mazzucotelli
9 | copyright_holder_email: pawamoy@pm.me
10 | copyright_license: ISC License
11 | insiders: false
12 | project_description: Show the inter-dependencies between modules of Python packages.
13 | project_name: Dependenpy
14 | python_package_command_line_name: dependenpy
15 | python_package_distribution_name: dependenpy
16 | python_package_import_name: dependenpy
17 | repository_name: dependenpy
18 | repository_namespace: pawamoy
19 | repository_provider: github.com
20 |
21 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: pawamoy
2 | ko_fi: pawamoy
3 | custom:
4 | - https://www.paypal.me/pawamoy
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: unconfirmed
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Run command '...'
17 | 3. Scroll down to '...'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **System (please complete the following information):**
27 | - `Dependenpy` version: [e.g. 0.2.1]
28 | - Python version: [e.g. 3.8]
29 | - OS: [Windows/Linux]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: feature
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | pull_request:
6 | branches:
7 | - main
8 |
9 | defaults:
10 | run:
11 | shell: bash
12 |
13 | env:
14 | LANG: en_US.utf-8
15 | LC_ALL: en_US.utf-8
16 | PYTHONIOENCODING: UTF-8
17 |
18 | jobs:
19 |
20 | quality:
21 |
22 | runs-on: ubuntu-latest
23 |
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v3
27 |
28 | - name: Fetch all tags
29 | run: git fetch --depth=1 --tags
30 |
31 | - name: Set up PDM
32 | uses: pdm-project/setup-pdm@v3
33 | with:
34 | python-version: "3.8"
35 |
36 | - name: Resolving dependencies
37 | run: pdm lock -v --no-cross-platform -G ci-quality
38 |
39 | - name: Install dependencies
40 | run: pdm install -G ci-quality
41 |
42 | - name: Check if the documentation builds correctly
43 | run: pdm run duty check-docs
44 |
45 | - name: Check the code quality
46 | run: pdm run duty check-quality
47 |
48 | - name: Check if the code is correctly typed
49 | run: pdm run duty check-types
50 |
51 | - name: Check for vulnerabilities in dependencies
52 | run: pdm run duty check-dependencies
53 |
54 | - name: Check for breaking changes in the API
55 | run: pdm run duty check-api
56 |
57 | tests:
58 |
59 | strategy:
60 | max-parallel: 4
61 | matrix:
62 | os:
63 | - ubuntu-latest
64 | - macos-latest
65 | - windows-latest
66 | python-version:
67 | - "3.8"
68 | - "3.9"
69 | - "3.10"
70 | - "3.11"
71 | - "3.12"
72 | runs-on: ${{ matrix.os }}
73 | continue-on-error: ${{ matrix.python-version == '3.12' }}
74 |
75 | steps:
76 | - name: Checkout
77 | uses: actions/checkout@v3
78 |
79 | - name: Set up PDM
80 | uses: pdm-project/setup-pdm@v3
81 | with:
82 | python-version: ${{ matrix.python-version }}
83 | allow-python-prereleases: true
84 |
85 | - name: Resolving dependencies
86 | run: pdm lock -v --no-cross-platform -G ci-tests
87 |
88 | - name: Install dependencies
89 | run: pdm install --no-editable -G ci-tests
90 |
91 | - name: Run the test suite
92 | run: pdm run duty test
93 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on: push
4 | permissions:
5 | contents: write
6 |
7 | jobs:
8 | release:
9 | runs-on: ubuntu-latest
10 | if: startsWith(github.ref, 'refs/tags/')
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v3
14 | - name: Fetch all tags
15 | run: git fetch --depth=1 --tags
16 | - name: Setup Python
17 | uses: actions/setup-python@v4
18 | - name: Install git-changelog
19 | run: pip install git-changelog
20 | - name: Prepare release notes
21 | run: git-changelog --release-notes > release-notes.md
22 | - name: Create release
23 | uses: softprops/action-gh-release@v1
24 | with:
25 | body_path: release-notes.md
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | __pycache__/
3 | *.py[cod]
4 | dist/
5 | *.egg-info/
6 | build/
7 | htmlcov/
8 | .coverage*
9 | pip-wheel-metadata/
10 | .pytest_cache/
11 | .mypy_cache/
12 | site/
13 | pdm.lock
14 | pdm.toml
15 | .pdm-plugins/
16 | .pdm-python
17 | __pypackages__/
18 | .venv/
19 | .demo_env/
20 | .cache/
21 |
--------------------------------------------------------------------------------
/.gitpod.dockerfile:
--------------------------------------------------------------------------------
1 | FROM gitpod/workspace-full
2 | USER gitpod
3 | ENV PIP_USER=no
4 | RUN pip3 install pipx; \
5 | pipx install pdm; \
6 | pipx ensurepath
7 |
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | vscode:
2 | extensions:
3 | - ms-python.python
4 |
5 | image:
6 | file: .gitpod.dockerfile
7 |
8 | ports:
9 | - port: 8000
10 | onOpen: notify
11 |
12 | tasks:
13 | - init: make setup
14 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6 |
7 |
8 | ## [3.3.2](https://github.com/pawamoy/dependenpy/releases/tag/3.3.2) - 2022-09-04
9 |
10 | [Compare with 3.3.1](https://github.com/pawamoy/dependenpy/compare/3.3.1...3.3.2)
11 |
12 | ### Bug Fixes
13 | - Add back missing colorama runtime dependency ([f3e274c](https://github.com/pawamoy/dependenpy/commit/f3e274cfde24f5a3cc1a200fceb4f2d94f41ca11) by Vlad Dumitrescu). References: [#57](https://github.com/pawamoy/dependenpy/issues/57)
14 | - Copy data when casting a matrix ([13ec81a](https://github.com/pawamoy/dependenpy/commit/13ec81ae077fb6cd508a53317b83b31be278799f) by Vlad Dumitrescu). References: [#53](https://github.com/pawamoy/dependenpy/issues/53)
15 |
16 |
17 | ## [3.3.1](https://github.com/pawamoy/dependenpy/releases/tag/3.3.1) - 2022-06-13
18 |
19 | [Compare with 3.3.0](https://github.com/pawamoy/dependenpy/compare/3.3.0...3.3.1)
20 |
21 | ### Bug Fixes
22 | - Handle the case where all modules names are shorter than the header when printing a matrix ([1d83b17](https://github.com/pawamoy/dependenpy/commit/1d83b17612cdf5ab16c27668bd241d64dd872e5c) by Vlad Dumitrescu). [PR #48](https://github.com/pawamoy/dependenpy/pull/48)
23 |
24 |
25 | ## [3.3.0](https://github.com/pawamoy/dependenpy/releases/tag/3.3.0) - 2020-09-04
26 |
27 | [Compare with 3.2.0](https://github.com/pawamoy/dependenpy/compare/3.2.0...3.3.0)
28 |
29 | ### Code Refactoring
30 | - Poetrize the project ([811c3fb](https://github.com/pawamoy/dependenpy/commit/811c3fb7271d6474a58f5b800bef5c220be3b8f6) by Timothée Mazzucotelli).
31 |
32 | ### Features
33 | - Add 'zero' argument to change character for 0 ([1c13c00](https://github.com/pawamoy/dependenpy/commit/1c13c000685466f46ad8c6f7ac30534a6efe9373) by Timothée Mazzucotelli).
34 | - Update archan provider for archan 3.0 ([9249dc1](https://github.com/pawamoy/dependenpy/commit/9249dc161e9fdd64e15a42f644232c43cb6875b2) by Timothée Mazzucotelli).
35 |
36 |
37 | ## [3.2.0](https://github.com/pawamoy/dependenpy/releases/tag/3.2.0) - 2017-06-27
38 |
39 | [Compare with 3.1.0](https://github.com/pawamoy/dependenpy/compare/3.1.0...3.2.0)
40 |
41 | ### Features
42 | - Add graph option ([1ebc8f6](https://github.com/pawamoy/dependenpy/commit/1ebc8f6d12cc5ceb0dcbbfd240c96bcbfa6f867e)).
43 | - Implement archan provider ([66edb5b](https://github.com/pawamoy/dependenpy/commit/66edb5be54544af78476514494c85dac84205f2b)).
44 |
45 |
46 | ## [3.1.0](https://github.com/pawamoy/dependenpy/releases/tag/3.1.0) - 2017-06-02
47 |
48 | [Compare with 3.0.0](https://github.com/pawamoy/dependenpy/compare/3.0.0...3.1.0)
49 |
50 | ### Features
51 | - Add `-i, --indent` option to specify indentation level.
52 |
53 | ### Changes
54 | - Change `-i, --enforce-init` option to its contrary `-g, --greedy`.
55 | - Options `-l`, `-m` and `-t` are now mutually exclusive.
56 |
57 | ### Bug fixes
58 | - Fix imports order ([9a9fcc3](https://github.com/pawamoy/dependenpy/commit/9a9fcc33c258a89eafcbf6995bebc64fccb85d54)).
59 | - Fix matrix build for depth=0 ([955cc21](https://github.com/pawamoy/dependenpy/commit/955cc210d6acf5dc83e39b41edbf26b95b09d7b0)).
60 |
61 | ### Misc
62 | - Improve cli tool and print methods,
63 |
64 |
65 | ## [3.0.0](https://github.com/pawamoy/dependenpy/releases/tag/3.0.0) - 2017-05-22
66 |
67 | [Compare with 2.0.3](https://github.com/pawamoy/dependenpy/compare/2.0.3...3.0.0)
68 |
69 | This version is a big refactoring. The code is way more object oriented,
70 | cleaner, shorter, simpler, smarter, more user friendly- in short: better.
71 |
72 | Additional features:
73 |
74 | - command line entry point,
75 | - runtime static imports are now caught (in functions or classes),
76 | as well as import statements (previously only from import).
77 |
78 |
79 | ## [2.0.3](https://github.com/pawamoy/dependenpy/releases/tag/2.0.3) - 2017-04-20
80 |
81 | [Compare with 2.0.2](https://github.com/pawamoy/dependenpy/compare/2.0.2...2.0.3)
82 |
83 | ### Changes
84 | - Change license from MPL 2.0 to ISC ([35400bf](https://github.com/pawamoy/dependenpy/commit/35400bf755c40e88a0e2bd9bd7a21b96194b0e1b)).
85 |
86 | ### Bug fixes
87 | - Fix occasional UnicodeEncode when reading utf8 file ([333e987](https://github.com/pawamoy/dependenpy/commit/333e98710d80976196367fb6fc2ed8f82313d117)).
88 | - Handle bad characters in files when parsing with ast ([200e014](https://github.com/pawamoy/dependenpy/commit/200e0147cc44fcd80c9b53115f63405107e2bfd3)).
89 |
90 |
91 | ## [2.0.2](https://github.com/pawamoy/dependenpy/releases/tag/2.0.2) - 2016-10-06
92 |
93 | [Compare with 1.0.4](https://github.com/pawamoy/dependenpy/compare/1.0.4...2.0.2)
94 |
95 | - Split code in two projects: dependenpy and archan.
96 | - Update to use Python 3.
97 | - Various bug fixes, additions, improvements and refactor.
98 |
99 | ## [1.0.4](https://github.com/pawamoy/dependenpy/releases/tag/1.0.4) - 2015-03-05
100 |
101 | [Compare with 1.0.3](https://github.com/pawamoy/dependenpy/compare/1.0.3...1.0.4)
102 |
103 | Documentation and tests improvements.
104 |
105 |
106 | ## [1.0.3](https://github.com/pawamoy/dependenpy/releases/tag/1.0.3) - 2015-02-26
107 |
108 | [Compare with 1.0.2](https://github.com/pawamoy/dependenpy/compare/1.0.2...1.0.3)
109 |
110 | ### Bug fixes
111 | - Add check for target_index not None ([d3e573f](https://github.com/pawamoy/dependenpy/commit/d3e573fcbc79957bc19dada4359663adb48a0a81)).
112 |
113 |
114 | ## [1.0.2](https://github.com/pawamoy/dependenpy/releases/tag/1.0.2) - 2015-02-24
115 |
116 | [Compare with 1.0.1](https://github.com/pawamoy/dependenpy/compare/1.0.1...1.0.2)
117 |
118 | ### Features
119 | - Added CSV export ([ce8a911](https://github.com/pawamoy/dependenpy/commit/ce8a91130e20e57208d45a93c83dfc47565c16e4)).
120 |
121 | ### Bug fixes
122 | - Fix get_matrix if str instead of int, fix csv write row (extend return None) ([bb1289d](https://github.com/pawamoy/dependenpy/commit/bb1289dc2c035f6f25fd6ab5cb29aa776f5d6bc6)).
123 |
124 |
125 | ## [1.0.1](https://github.com/pawamoy/dependenpy/releases/tag/1.0.1) - 2015-02-23
126 |
127 | [Compare with 1.0](https://github.com/pawamoy/dependenpy/compare/1.0...1.0.1)
128 |
129 | ### Bug fixes
130 | - Fix hashable for dict ([7d221db](https://github.com/pawamoy/dependenpy/commit/7d221db07766f41d942c947f621286e21ad17b48)).
131 | - Fix path resolver ([4e8a192](https://github.com/pawamoy/dependenpy/commit/4e8a19211648255365477a8b6d83d538463f8488)).
132 |
133 |
134 | ## [1.0](https://github.com/pawamoy/dependenpy/releases/tag/1.0) - 2015-02-23
135 |
136 | [Compare with 0.2-beta](https://github.com/pawamoy/dependenpy/compare/0.2-beta...1.0)
137 |
138 | ## Code refactoring
139 |
140 | - [4bd14d9](https://github.com/pawamoy/dependenpy/commit/4bd14d92d842b173b2456c3ff0083b84960545ad)
141 | - [15ba1e5](https://github.com/pawamoy/dependenpy/commit/15ba1e54700896abdaccc3fefcdc261d73be1368)
142 | - [12fa604](https://github.com/pawamoy/dependenpy/commit/12fa60444a83c11644026270c1df37eddaecc2c8)
143 |
144 |
145 | ## [0.2-beta](https://github.com/pawamoy/dependenpy/releases/tag/0.2-beta) - 2015-02-20
146 |
147 | [Compare with first commit](https://github.com/pawamoy/dependenpy/compare/1ed68a25fb858a9da721a4cd3ab24fcc5f5e08a5...0.2-beta)
148 |
149 | First release.
150 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | pawamoy@pm.me.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
134 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are welcome, and they are greatly appreciated!
4 | Every little bit helps, and credit will always be given.
5 |
6 | ## Environment setup
7 |
8 | Nothing easier!
9 |
10 | Fork and clone the repository, then:
11 |
12 | ```bash
13 | cd dependenpy
14 | make setup
15 | ```
16 |
17 | > NOTE:
18 | > If it fails for some reason,
19 | > you'll need to install
20 | > [PDM](https://github.com/pdm-project/pdm)
21 | > manually.
22 | >
23 | > You can install it with:
24 | >
25 | > ```bash
26 | > python3 -m pip install --user pipx
27 | > pipx install pdm
28 | > ```
29 | >
30 | > Now you can try running `make setup` again,
31 | > or simply `pdm install`.
32 |
33 | You now have the dependencies installed.
34 |
35 | You can run the application with `pdm run dependenpy [ARGS...]`.
36 |
37 | Run `make help` to see all the available actions!
38 |
39 | ## Tasks
40 |
41 | This project uses [duty](https://github.com/pawamoy/duty) to run tasks.
42 | A Makefile is also provided. The Makefile will try to run certain tasks
43 | on multiple Python versions. If for some reason you don't want to run the task
44 | on multiple Python versions, you run the task directly with `pdm run duty TASK`.
45 |
46 | The Makefile detects if a virtual environment is activated,
47 | so `make` will work the same with the virtualenv activated or not.
48 |
49 | If you work in VSCode,
50 | [see examples of tasks and run configurations](https://pawamoy.github.io/copier-pdm/work/#vscode-setup).
51 |
52 | ## Development
53 |
54 | As usual:
55 |
56 | 1. create a new branch: `git switch -c feature-or-bugfix-name`
57 | 1. edit the code and/or the documentation
58 |
59 | **Before committing:**
60 |
61 | 1. run `make format` to auto-format the code
62 | 1. run `make check` to check everything (fix any warning)
63 | 1. run `make test` to run the tests (fix any issue)
64 | 1. if you updated the documentation or the project dependencies:
65 | 1. run `make docs`
66 | 1. go to http://localhost:8000 and check that everything looks good
67 | 1. follow our [commit message convention](#commit-message-convention)
68 |
69 | If you are unsure about how to fix or ignore a warning,
70 | just let the continuous integration fail,
71 | and we will help you during review.
72 |
73 | Don't bother updating the changelog, we will take care of this.
74 |
75 | ## Commit message convention
76 |
77 | Commit messages must follow our convention based on the
78 | [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message)
79 | or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html):
80 |
81 | ```
82 | [(scope)]: Subject
83 |
84 | [Body]
85 | ```
86 |
87 | **Subject and body must be valid Markdown.**
88 | Subject must have proper casing (uppercase for first letter
89 | if it makes sense), but no dot at the end, and no punctuation
90 | in general.
91 |
92 | Scope and body are optional. Type can be:
93 |
94 | - `build`: About packaging, building wheels, etc.
95 | - `chore`: About packaging or repo/files management.
96 | - `ci`: About Continuous Integration.
97 | - `deps`: Dependencies update.
98 | - `docs`: About documentation.
99 | - `feat`: New feature.
100 | - `fix`: Bug fix.
101 | - `perf`: About performance.
102 | - `refactor`: Changes that are not features or bug fixes.
103 | - `style`: A change in code style/format.
104 | - `tests`: About tests.
105 |
106 | If you write a body, please add trailers at the end
107 | (for example issues and PR references, or co-authors),
108 | without relying on GitHub's flavored Markdown:
109 |
110 | ```
111 | Body.
112 |
113 | Issue #10: https://github.com/namespace/project/issues/10
114 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15
115 | ```
116 |
117 | These "trailers" must appear at the end of the body,
118 | without any blank lines between them. The trailer title
119 | can contain any character except colons `:`.
120 | We expect a full URI for each trailer, not just GitHub autolinks
121 | (for example, full GitHub URLs for commits and issues,
122 | not the hash or the #issue-number).
123 |
124 | We do not enforce a line length on commit messages summary and body,
125 | but please avoid very long summaries, and very long lines in the body,
126 | unless they are part of code blocks that must not be wrapped.
127 |
128 | ## Pull requests guidelines
129 |
130 | Link to any related issue in the Pull Request message.
131 |
132 | During the review, we recommend using fixups:
133 |
134 | ```bash
135 | # SHA is the SHA of the commit you want to fix
136 | git commit --fixup=SHA
137 | ```
138 |
139 | Once all the changes are approved, you can squash your commits:
140 |
141 | ```bash
142 | git rebase -i --autosquash main
143 | ```
144 |
145 | And force-push:
146 |
147 | ```bash
148 | git push -f
149 | ```
150 |
151 | If this seems all too complicated, you can push or force-push each new commit,
152 | and we will squash them ourselves if needed, before merging.
153 |
--------------------------------------------------------------------------------
/CREDITS.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | # Credits
6 | These projects were used to build `dependenpy`. **Thank you!**
7 |
8 | [`python`](https://www.python.org/) |
9 | [`poetry`](https://poetry.eustace.io/) |
10 | [`copier-poetry`](https://github.com/pawamoy/copier-poetry)
11 |
12 | ### Direct dependencies
13 | [`autoflake`](https://github.com/myint/autoflake) |
14 | [`black`](https://github.com/psf/black) |
15 | [`colorama`](https://github.com/tartley/colorama) |
16 | [`coverage`](https://github.com/nedbat/coveragepy) |
17 | [`coverage`](https://github.com/nedbat/coveragepy) |
18 | [`failprint`](https://github.com/pawamoy/failprint) |
19 | [`flake8-black`](https://github.com/peterjc/flake8-black) |
20 | [`flake8-builtins`](https://github.com/gforcada/flake8-builtins) |
21 | [`flake8-pytest-style`](https://pypi.org/project/flake8-pytest-style) |
22 | [`flake8-tidy-imports`](https://github.com/adamchainz/flake8-tidy-imports) |
23 | [`flake8-variables-names`](https://github.com/best-doctor/flake8-variables-names) |
24 | [`flakehell`](None) |
25 | [`git-changelog`](https://github.com/pawamoy/git-changelog) |
26 | [`httpx`](https://github.com/encode/httpx) |
27 | [`invoke`](http://docs.pyinvoke.org) |
28 | [`invoke`](http://docs.pyinvoke.org) |
29 | [`ipython`](https://ipython.org) |
30 | [`isort`](https://github.com/timothycrosley/isort) |
31 | [`jinja2-cli`](https://github.com/mattrobenolt/jinja2-cli) |
32 | [`mkdocs`](https://www.mkdocs.org) |
33 | [`mkdocs-material`](https://squidfunk.github.io/mkdocs-material/) |
34 | [`mkdocstrings`](https://github.com/pawamoy/mkdocstrings) |
35 | [`mypy`](http://www.mypy-lang.org/) |
36 | [`mypy`](http://www.mypy-lang.org/) |
37 | [`pytest`](https://docs.pytest.org/en/latest/) |
38 | [`pytest`](https://docs.pytest.org/en/latest/) |
39 | [`pytest-randomly`](https://github.com/pytest-dev/pytest-randomly) |
40 | [`pytest-randomly`](https://github.com/pytest-dev/pytest-randomly) |
41 | [`pytest-sugar`](http://pivotfinland.com/pytest-sugar/) |
42 | [`pytest-sugar`](http://pivotfinland.com/pytest-sugar/) |
43 | [`pytest-xdist`](https://github.com/pytest-dev/pytest-xdist) |
44 | [`pytest-xdist`](https://github.com/pytest-dev/pytest-xdist) |
45 | [`toml`](https://github.com/uiri/toml) |
46 | [`wemake-python-styleguide`](https://wemake-python-stylegui.de) |
47 | [`wrapt`](https://github.com/GrahamDumpleton/wrapt)
48 |
49 | ### Indirect dependencies
50 | [`ansimarkup`](https://github.com/gvalkov/python-ansimarkup) |
51 | [`apipkg`](https://github.com/pytest-dev/apipkg) |
52 | [`appdirs`](http://github.com/ActiveState/appdirs) |
53 | [`appnope`](http://github.com/minrk/appnope) |
54 | [`astor`](https://github.com/berkerpeksag/astor) |
55 | [`astroid`](https://github.com/PyCQA/astroid) |
56 | [`atomicwrites`](https://github.com/untitaker/python-atomicwrites) |
57 | [`attrs`](https://www.attrs.org/) |
58 | [`backcall`](https://github.com/takluyver/backcall) |
59 | [`bandit`](https://bandit.readthedocs.io/en/latest/) |
60 | [`beautifulsoup4`](http://www.crummy.com/software/BeautifulSoup/bs4/) |
61 | [`certifi`](https://certifiio.readthedocs.io/en/latest/) |
62 | [`chardet`](https://github.com/chardet/chardet) |
63 | [`click`](https://palletsprojects.com/p/click/) |
64 | [`contextvars`](http://github.com/MagicStack/contextvars) |
65 | [`darglint`](None) |
66 | [`dataclasses`](https://github.com/ericvsmith/dataclasses) |
67 | [`decorator`](https://github.com/micheles/decorator) |
68 | [`docutils`](http://docutils.sourceforge.net/) |
69 | [`entrypoints`](https://github.com/takluyver/entrypoints) |
70 | [`eradicate`](https://github.com/myint/eradicate) |
71 | [`execnet`](https://execnet.readthedocs.io/en/latest/) |
72 | [`flake8`](https://gitlab.com/pycqa/flake8) |
73 | [`flake8-bandit`](https://github.com/tylerwince/flake8-bandit) |
74 | [`flake8-broken-line`](https://github.com/sobolevn/flake8-broken-line) |
75 | [`flake8-bugbear`](https://github.com/PyCQA/flake8-bugbear) |
76 | [`flake8-commas`](https://github.com/PyCQA/flake8-commas/) |
77 | [`flake8-comprehensions`](https://github.com/adamchainz/flake8-comprehensions) |
78 | [`flake8-debugger`](https://github.com/jbkahn/flake8-debugger) |
79 | [`flake8-docstrings`](https://gitlab.com/pycqa/flake8-docstrings) |
80 | [`flake8-eradicate`](https://github.com/sobolevn/flake8-eradicate) |
81 | [`flake8-isort`](https://github.com/gforcada/flake8-isort) |
82 | [`flake8-plugin-utils`](https://pypi.org/project/flake8-plugin-utils) |
83 | [`flake8-polyfill`](https://gitlab.com/pycqa/flake8-polyfill) |
84 | [`flake8-quotes`](http://github.com/zheller/flake8-quotes/) |
85 | [`flake8-rst-docstrings`](https://github.com/peterjc/flake8-rst-docstrings) |
86 | [`flake8-string-format`](https://github.com/xZise/flake8-string-format) |
87 | [`future`](https://python-future.org) |
88 | [`gitdb`](https://github.com/gitpython-developers/gitdb) |
89 | [`GitPython`](https://github.com/gitpython-developers/GitPython) |
90 | [`h11`](https://github.com/python-hyper/h11) |
91 | [`h2`](https://github.com/python-hyper/hyper-h2) |
92 | [`hpack`](http://hyper.rtfd.org) |
93 | [`hstspreload`](https://github.com/sethmlarson/hstspreload) |
94 | [`httpcore`](https://github.com/encode/httpcore) |
95 | [`hyperframe`](https://python-hyper.org/hyperframe/en/latest/) |
96 | [`idna`](https://github.com/kjd/idna) |
97 | [`immutables`](https://github.com/MagicStack/immutables) |
98 | [`importlib-metadata`](http://importlib-metadata.readthedocs.io/) |
99 | [`ipython-genutils`](http://ipython.org) |
100 | [`jedi`](https://github.com/davidhalter/jedi) |
101 | [`Jinja2`](https://palletsprojects.com/p/jinja/) |
102 | [`joblib`](https://joblib.readthedocs.io) |
103 | [`lazy-object-proxy`](https://github.com/ionelmc/python-lazy-object-proxy) |
104 | [`livereload`](https://github.com/lepture/python-livereload) |
105 | [`lunr`](https://github.com/yeraydiazdiaz/lunr.py) |
106 | [`Markdown`](https://Python-Markdown.github.io/) |
107 | [`MarkupSafe`](https://palletsprojects.com/p/markupsafe/) |
108 | [`mccabe`](https://github.com/pycqa/mccabe) |
109 | [`mkdocs-material-extensions`](https://github.com/facelessuser/mkdocs-material-extensions) |
110 | [`more-itertools`](https://github.com/more-itertools/more-itertools) |
111 | [`mypy-extensions`](https://github.com/python/mypy_extensions) |
112 | [`nltk`](http://nltk.org/) |
113 | [`packaging`](https://github.com/pypa/packaging) |
114 | [`parso`](https://github.com/davidhalter/parso) |
115 | [`pathspec`](https://github.com/cpburnz/python-path-specification) |
116 | [`pbr`](https://docs.openstack.org/pbr/latest/) |
117 | [`pep8-naming`](https://github.com/PyCQA/pep8-naming) |
118 | [`pexpect`](https://pexpect.readthedocs.io/) |
119 | [`pickleshare`](https://github.com/pickleshare/pickleshare) |
120 | [`pluggy`](https://github.com/pytest-dev/pluggy) |
121 | [`prompt-toolkit`](https://github.com/prompt-toolkit/python-prompt-toolkit) |
122 | [`ptyprocess`](https://github.com/pexpect/ptyprocess) |
123 | [`py`](https://py.readthedocs.io/) |
124 | [`pycodestyle`](https://pycodestyle.readthedocs.io/) |
125 | [`pydocstyle`](https://github.com/PyCQA/pydocstyle/) |
126 | [`pyflakes`](https://github.com/PyCQA/pyflakes) |
127 | [`Pygments`](https://pygments.org/) |
128 | [`pylint`](https://github.com/PyCQA/pylint) |
129 | [`pymdown-extensions`](https://github.com/facelessuser/pymdown-extensions) |
130 | [`pyparsing`](https://github.com/pyparsing/pyparsing/) |
131 | [`pytest-forked`](https://github.com/pytest-dev/pytest-forked) |
132 | [`pytkdocs`](https://github.com/pawamoy/pytkdocs) |
133 | [`PyYAML`](https://github.com/yaml/pyyaml) |
134 | [`regex`](https://bitbucket.org/mrabarnett/mrab-regex) |
135 | [`restructuredtext-lint`](https://github.com/twolfson/restructuredtext-lint) |
136 | [`rfc3986`](http://rfc3986.readthedocs.io) |
137 | [`six`](https://github.com/benjaminp/six) |
138 | [`smmap`](https://github.com/gitpython-developers/smmap) |
139 | [`sniffio`](https://github.com/python-trio/sniffio) |
140 | [`snowballstemmer`](https://github.com/snowballstem/snowball) |
141 | [`soupsieve`](https://github.com/facelessuser/soupsieve) |
142 | [`stevedore`](https://docs.openstack.org/stevedore/latest/) |
143 | [`termcolor`](http://pypi.python.org/pypi/termcolor) |
144 | [`testfixtures`](https://github.com/Simplistix/testfixtures) |
145 | [`tornado`](http://www.tornadoweb.org/) |
146 | [`tqdm`](https://github.com/tqdm/tqdm) |
147 | [`traitlets`](http://ipython.org) |
148 | [`typed-ast`](https://github.com/python/typed_ast) |
149 | [`typing-extensions`](https://github.com/python/typing/blob/master/typing_extensions/README.rst) |
150 | [`urllib3`](https://urllib3.readthedocs.io/) |
151 | [`wcwidth`](https://github.com/jquast/wcwidth) |
152 | [`zipp`](https://github.com/jaraco/zipp)
153 |
154 | **[More credits from the author](http://pawamoy.github.io/credits/)**
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright (c) 2020, Timothée Mazzucotelli
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any
6 | purpose with or without fee is hereby granted, provided that the above
7 | copyright notice and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .DEFAULT_GOAL := help
2 | SHELL := bash
3 | DUTY := $(if $(VIRTUAL_ENV),,pdm run) duty
4 | export PDM_MULTIRUN_VERSIONS ?= 3.8 3.9 3.10 3.11 3.12
5 |
6 | args = $(foreach a,$($(subst -,_,$1)_args),$(if $(value $a),$a="$($a)"))
7 | check_quality_args = files
8 | docs_args = host port
9 | release_args = version
10 | test_args = match
11 |
12 | BASIC_DUTIES = \
13 | changelog \
14 | check-api \
15 | check-dependencies \
16 | clean \
17 | coverage \
18 | docs \
19 | docs-deploy \
20 | format \
21 | release
22 |
23 | QUALITY_DUTIES = \
24 | check-quality \
25 | check-docs \
26 | check-types \
27 | test
28 |
29 | .PHONY: help
30 | help:
31 | @$(DUTY) --list
32 |
33 | .PHONY: lock
34 | lock:
35 | @pdm lock -G:all
36 |
37 | .PHONY: setup
38 | setup:
39 | @bash scripts/setup.sh
40 |
41 | .PHONY: check
42 | check:
43 | @pdm multirun duty check-quality check-types check-docs
44 | @$(DUTY) check-dependencies check-api
45 |
46 | .PHONY: $(BASIC_DUTIES)
47 | $(BASIC_DUTIES):
48 | @$(DUTY) $@ $(call args,$@)
49 |
50 | .PHONY: $(QUALITY_DUTIES)
51 | $(QUALITY_DUTIES):
52 | @pdm multirun duty $@ $(call args,$@)
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dependenpy
2 |
3 | [](https://github.com/pawamoy/dependenpy/actions?query=workflow%3Aci)
4 | [](https://pawamoy.github.io/dependenpy/)
5 | [](https://pypi.org/project/dependenpy/)
6 | [](https://gitpod.io/#https://github.com/pawamoy/dependenpy)
7 | [](https://gitter.im/dependenpy/community)
8 |
9 | Show the inter-dependencies between modules of Python packages.
10 |
11 | `dependenpy` allows you to build a dependency matrix for a set of Python packages.
12 | To do this, it reads and searches the source code for import statements.
13 |
14 | 
15 |
16 | ## Installation
17 |
18 | With `pip`:
19 | ```bash
20 | pip install dependenpy
21 | ```
22 |
23 | With [`pipx`](https://github.com/pipxproject/pipx):
24 | ```bash
25 | python3.8 -m pip install --user pipx
26 | pipx install dependenpy
27 | ```
28 |
29 | ## Usage (as a library)
30 |
31 | ```python
32 | from dependenpy import DSM
33 |
34 | # create DSM
35 | dsm = DSM('django')
36 |
37 | # transform as matrix
38 | matrix = dsm.as_matrix(depth=2)
39 |
40 | # initialize with many packages
41 | dsm = DSM('django', 'meerkat', 'appsettings', 'dependenpy', 'archan')
42 | with open('output', 'w') as output:
43 | dsm.print(format='json', indent=2, output=output)
44 |
45 | # access packages and modules
46 | meerkat = dsm['meerkat'] # or dsm.get('meerkat')
47 | finder = dsm['dependenpy.finder'] # or even dsm['dependenpy']['finder']
48 |
49 | # instances of DSM and Package all have print, as_matrix, etc. methods
50 | meerkat.print_matrix(depth=2)
51 | ```
52 |
53 | This package was originally design to work in a Django project.
54 | The Django package [django-meerkat](https://github.com/Genida/django-meerkat)
55 | uses it to display the matrices with Highcharts.
56 |
57 | ## Usage (command-line)
58 |
59 | ```
60 | usage: dependenpy [-d DEPTH] [-f {csv,json,text}] [-g] [-G] [-h]
61 | [-i INDENT] [-l] [-m] [-o OUTPUT] [-t] [-v]
62 | [-z STRING] PACKAGES [PACKAGES ...]
63 |
64 | Command line tool for dependenpy Python package.
65 |
66 | positional arguments:
67 | PACKAGES The package list. Can be a comma-separated list. Each
68 | package must be either a valid path or a package in
69 | PYTHONPATH.
70 |
71 | optional arguments:
72 | -d DEPTH, --depth DEPTH
73 | Specify matrix or graph depth. Default: best guess.
74 | -f {csv,json,text}, --format {csv,json,text}
75 | Output format. Default: text.
76 | -g, --show-graph Show the graph (no text format). Default: false.
77 | -G, --greedy Explore subdirectories even if they do not contain an
78 | __init__.py file. Can make execution slower. Default:
79 | false.
80 | -h, --help Show this help message and exit.
81 | -i INDENT, --indent INDENT
82 | Specify output indentation. CSV will never be
83 | indented. Text will always have new-lines. JSON can be
84 | minified with a negative value. Default: best guess.
85 | -l, --show-dependencies-list
86 | Show the dependencies list. Default: false.
87 | -m, --show-matrix Show the matrix. Default: true unless -g, -l or -t.
88 | -o OUTPUT, --output OUTPUT
89 | Output to given file. Default: stdout.
90 | -t, --show-treemap Show the treemap (work in progress). Default: false.
91 | -v, --version Show the current version of the program and exit.
92 | -z ZERO, --zero ZERO Character to use for cells with value=0 (text matrix
93 | display only). Default: "0".
94 |
95 | ```
96 |
97 | Example:
98 |
99 | ```console
100 | $ # running dependenpy on itself
101 | $ dependenpy dependenpy -z=
102 |
103 | Module │ Id │0│1│2│3│4│5│6│7│8│
104 | ──────────────────────┼────┼─┼─┼─┼─┼─┼─┼─┼─┼─┤
105 | dependenpy.__init__ │ 0 │ │ │ │4│ │ │ │ │2│
106 | dependenpy.__main__ │ 1 │ │ │1│ │ │ │ │ │ │
107 | dependenpy.cli │ 2 │1│ │ │1│ │4│ │ │ │
108 | dependenpy.dsm │ 3 │ │ │ │ │2│1│3│ │ │
109 | dependenpy.finder │ 4 │ │ │ │ │ │ │ │ │ │
110 | dependenpy.helpers │ 5 │ │ │ │ │ │ │ │ │ │
111 | dependenpy.node │ 6 │ │ │ │ │ │ │ │ │3│
112 | dependenpy.plugins │ 7 │ │ │ │1│ │1│ │ │ │
113 | dependenpy.structures │ 8 │ │ │ │ │ │1│ │ │ │
114 |
115 | ```
116 |
--------------------------------------------------------------------------------
/config/archan.yml:
--------------------------------------------------------------------------------
1 | analyzers:
2 | - providers:
3 | - dependenpy.InternalDependencies:
4 | packages:
5 | - dependenpy
6 | checkers:
7 | - archan.CompleteMediation
8 | - archan.EconomyOfMechanism:
9 | simplicity_factor: 2
10 | - archan.LayeredArchitecture
11 | - archan.LeastCommonMechanism:
12 | independence_factor: 5
13 | - archan.OpenDesign:
14 | ok: true
15 |
--------------------------------------------------------------------------------
/config/black.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 120
3 | exclude = "tests/fixtures"
4 |
--------------------------------------------------------------------------------
/config/coverage.ini:
--------------------------------------------------------------------------------
1 | [coverage:run]
2 | branch = true
3 | parallel = true
4 | source =
5 | src/
6 | tests/
7 |
8 | [coverage:paths]
9 | equivalent =
10 | src/
11 | __pypackages__/
12 |
13 | [coverage:report]
14 | precision = 2
15 | omit =
16 | src/*/__init__.py
17 | src/*/__main__.py
18 | tests/__init__.py
19 | exclude_lines =
20 | pragma: no cover
21 | if TYPE_CHECKING
22 |
23 | [coverage:json]
24 | output = htmlcov/coverage.json
25 |
--------------------------------------------------------------------------------
/config/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | ignore_missing_imports = true
3 | exclude = tests/fixtures/
4 | warn_unused_ignores = true
5 | show_error_codes = true
6 |
--------------------------------------------------------------------------------
/config/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | norecursedirs =
3 | .git
4 | .tox
5 | .env
6 | dist
7 | build
8 | python_files =
9 | test_*.py
10 | *_test.py
11 | tests.py
12 | addopts =
13 | --cov
14 | --cov-config config/coverage.ini
15 | testpaths =
16 | tests
17 |
18 | # action:message_regex:warning_class:module_regex:line
19 | filterwarnings =
20 | error
21 | # TODO: remove once pytest-xdist 4 is released
22 | ignore:.*rsyncdir:DeprecationWarning:xdist
23 |
--------------------------------------------------------------------------------
/config/ruff.toml:
--------------------------------------------------------------------------------
1 | target-version = "py38"
2 | line-length = 132
3 | exclude = [
4 | "fixtures",
5 | "site",
6 | ]
7 | select = [
8 | "A",
9 | "ANN",
10 | "ARG",
11 | "B",
12 | "BLE",
13 | "C",
14 | "C4",
15 | "COM",
16 | "D",
17 | "DTZ",
18 | "E",
19 | "ERA",
20 | "EXE",
21 | "F",
22 | "FBT",
23 | "G",
24 | "I",
25 | "ICN",
26 | "INP",
27 | "ISC",
28 | "N",
29 | "PGH",
30 | "PIE",
31 | "PL",
32 | "PLC",
33 | "PLE",
34 | "PLR",
35 | "PLW",
36 | "PT",
37 | "PYI",
38 | "Q",
39 | "RUF",
40 | "RSE",
41 | "RET",
42 | "S",
43 | "SIM",
44 | "SLF",
45 | "T",
46 | "T10",
47 | "T20",
48 | "TCH",
49 | "TID",
50 | "TRY",
51 | "UP",
52 | "W",
53 | "YTT",
54 | ]
55 | ignore = [
56 | "A001", # Variable is shadowing a Python builtin
57 | "ANN101", # Missing type annotation for self
58 | "ANN102", # Missing type annotation for cls
59 | "ANN204", # Missing return type annotation for special method __str__
60 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed
61 | "ARG005", # Unused lambda argument
62 | "C901", # Too complex
63 | "D105", # Missing docstring in magic method
64 | "D417", # Missing argument description in the docstring
65 | "E501", # Line too long
66 | "ERA001", # Commented out code
67 | "G004", # Logging statement uses f-string
68 | "PLR0911", # Too many return statements
69 | "PLR0912", # Too many branches
70 | "PLR0913", # Too many arguments to function call
71 | "PLR0915", # Too many statements
72 | "SLF001", # Private member accessed
73 | "TRY003", # Avoid specifying long messages outside the exception class
74 | ]
75 |
76 | [per-file-ignores]
77 | "src/*/cli.py" = [
78 | "T201", # Print statement
79 | ]
80 | "scripts/*.py" = [
81 | "INP001", # File is part of an implicit namespace package
82 | "T201", # Print statement
83 | ]
84 | "tests/*.py" = [
85 | "ARG005", # Unused lambda argument
86 | "FBT001", # Boolean positional arg in function definition
87 | "PLR2004", # Magic value used in comparison
88 | "S101", # Use of assert detected
89 | ]
90 |
91 | [flake8-quotes]
92 | docstring-quotes = "double"
93 |
94 | [flake8-tidy-imports]
95 | ban-relative-imports = "all"
96 |
97 | [isort]
98 | known-first-party = ["dependenpy"]
99 |
100 | [pydocstyle]
101 | convention = "google"
102 |
--------------------------------------------------------------------------------
/demo.script:
--------------------------------------------------------------------------------
1 | # demo: charinterval=0.1
2 | if [ ! -d .demo_env ]; then
3 | poetry run python -m venv .demo_env
4 | .demo_env/bin/python -m pip install -U pip
5 | .demo_env/bin/python -m pip install . django asciinema
6 | fi
7 | . .demo_env/bin/activate
8 | clear # demo: charinterval=0.05
9 | termtosvg -g 130x80 # demo: sleep 2
10 | dependenpy django # demo: sleep 3
11 | clear
12 | dependenpy --depth=4 django.contrib.auth # demo: sleep 3
13 | clear
14 | dependenpy -d4 --format=csv django.contrib.auth # demo: sleep 3
15 | clear
16 | dependenpy -l dependenpy | head -n30 # demo: sleep 5
17 | clear
18 | dependenpy -l dependenpy | grep -v '!' | head -n30 # demo: sleep 5
19 | clear
20 | dependenpy -l --format json --indent 2 asciinema | head -n30 # demo: sleep 3
21 | clear
22 | dependenpy -lfjson asciinema | \
23 | jq '.packages[0].modules[] | .name, (.dependencies | length)' # demo: sleep 4
24 | exit
25 |
--------------------------------------------------------------------------------
/docs/.overrides/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block announce %}
4 |
5 | For updates follow @pawamoy on
6 |
7 |
8 | {% include ".icons/fontawesome/brands/mastodon.svg" %}
9 |
10 | Fosstodon
11 |
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/docs/changelog.md:
--------------------------------------------------------------------------------
1 | --8<-- "CHANGELOG.md"
2 |
--------------------------------------------------------------------------------
/docs/code_of_conduct.md:
--------------------------------------------------------------------------------
1 | --8<-- "CODE_OF_CONDUCT.md"
2 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | --8<-- "CONTRIBUTING.md"
2 |
--------------------------------------------------------------------------------
/docs/credits.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - toc
4 | ---
5 |
6 | ```python exec="yes"
7 | --8<-- "scripts/gen_credits.py"
8 | ```
9 |
--------------------------------------------------------------------------------
/docs/css/material.css:
--------------------------------------------------------------------------------
1 | /* More space at the bottom of the page. */
2 | .md-main__inner {
3 | margin-bottom: 1.5rem;
4 | }
5 |
--------------------------------------------------------------------------------
/docs/css/mkdocstrings.css:
--------------------------------------------------------------------------------
1 | /* Indentation. */
2 | div.doc-contents:not(.first) {
3 | padding-left: 25px;
4 | border-left: .05rem solid var(--md-typeset-table-color);
5 | }
6 |
7 | /* Mark external links as such. */
8 | a.external::after,
9 | a.autorefs-external::after {
10 | /* https://primer.style/octicons/arrow-up-right-24 */
11 | mask-image: url('data:image/svg+xml,');
12 | -webkit-mask-image: url('data:image/svg+xml,');
13 | content: ' ';
14 |
15 | display: inline-block;
16 | vertical-align: middle;
17 | position: relative;
18 |
19 | height: 1em;
20 | width: 1em;
21 | background-color: var(--md-typeset-a-color);
22 | }
23 |
24 | a.external:hover::after,
25 | a.autorefs-external:hover::after {
26 | background-color: var(--md-accent-fg-color);
27 | }
--------------------------------------------------------------------------------
/docs/demo.svg:
--------------------------------------------------------------------------------
1 | ../demo.svg
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | --8<-- "README.md"
2 |
--------------------------------------------------------------------------------
/docs/license.md:
--------------------------------------------------------------------------------
1 | # License
2 |
3 | ```
4 | --8<-- "LICENSE"
5 | ```
6 |
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | ## Importing classes
4 |
5 | You can directly import the following classes from `dependenpy`:
6 | `DSM`, `Package`, `Module`, `Dependency`, `Matrix` and `TreeMap`.
7 |
8 | If you need to import other classes, please take a look at the structure
9 | of the code.
10 |
11 | Example:
12 |
13 | ```python
14 | from dependenpy import DSM, Matrix
15 | ```
16 |
17 | ## Creation of objects
18 |
19 | For basic usage, you only have to instantiate a `DSM` object, and
20 | sometimes `Matrix` and `TreeMap`. But if you need to do more complicated
21 | stuff, you might also want to build instances of `Package`, `Module`
22 | or `Dependency`.
23 |
24 | ### Create a DSM
25 |
26 | To create a `DSM` object, just pass it a list of packages that can be either
27 | found on the disk (absolute or relative paths), or in the Python path (like
28 | in `sys.path`).
29 |
30 | ```python
31 | from dependenpy import DSM
32 |
33 | django = DSM("django")
34 | flask = DSM("flask")
35 | both = DSM("django", "flask")
36 | ```
37 |
38 | Three keyword arguments can be given to `DSM`:
39 |
40 | - `build_tree`: Boolean
41 | - `build_dependencies`: Boolean
42 | - `enforce_init`: Boolean
43 |
44 | The three of them defaults to true.
45 |
46 | Turning `build_tree` to false will delay the build of the Python package
47 | tree (the exploration of files on the file system).
48 | You can later call `dsm.build_tree()` to build the tree.
49 |
50 | Turning `build_dependencies` to false will delay the build of the
51 | dependencies (the parsing of the source code to determine the
52 | inter-dependencies).
53 | You can later call `dsm.build_dependencies()` to build the dependencies.
54 | Note that you won't be able to build the dependencies before the tree has
55 | been built.
56 |
57 | Using true for both `build_tree` and `build_dependencies` is recommended
58 | since it is done pretty quickly, even for big projects like Django.
59 |
60 | Turning `enforce_init` to false will make the exploration of sub-directories
61 | complete: by default, a sub-directory is not explored if it does not contain
62 | an `__init__.py` file. It makes the building of the tree faster. But in some
63 | cases, you might want to still explore the sub-directory even without
64 | `__init__.py`. In that case, use `enforce_init=False`. Note that
65 | depending on the tree, the build might take longer.
66 |
67 | ### Create a Package
68 |
69 | To create a `Package` object, initialize it with a name and a path.
70 | These two arguments are the only one required. Name should be the name of
71 | the Python package (the name of the directory), and path should be
72 | the path to the directory on the file system.
73 |
74 | Example:
75 |
76 | ```python
77 | from dependenpy import Package
78 |
79 | absolute_package = Package("django", "/my/virtualenv/lib/python3.5/site-packages/django")
80 | relative_package = Package("program", "src/program")
81 | ```
82 |
83 | Additionally, you can pass 6 more keyword arguments: the same three from
84 | `DSM` (`build_tree`, `build_dependencies` and `enforce_init`), and
85 | the three following:
86 |
87 | - `dsm`: parent DSM (instance of DSM).
88 | - `package`: parent package (instance of Package).
89 | - `limit_to`: list of strings to limit the exploration to a subset of
90 | directories.
91 |
92 | These three arguments default to `None`. Both `dsm` and `package`
93 | arguments are useful to build a tree.
94 |
95 | Argument `limit_to` can be used this way:
96 |
97 | ```python
98 | from dependenpy import Package
99 |
100 | django_auth = Package("django", "path/to/django", limit_to=["contrib.auth"])
101 | ```
102 |
103 | Of course, you could also have build a the `django_auth` Package by directly
104 | specify the name and path of the sub-directory, but using limit_to allows you
105 | to build the full tree, starting at the root (Django's directory).
106 |
107 | ```python
108 | from dependenpy import Package
109 |
110 | django_auth = Package("auth", "path/to/django/contrib/auth")
111 | ```
112 |
113 | ### Create a Module
114 |
115 | To create a `Module` object, initialize it with a name and a path.
116 | These two arguments are the only one required. Name should be the name of
117 | the Python module (the file without the `.py` extension), and path should be
118 | the path to the file on the file system.
119 |
120 | As for `Package`, `dsm` and `package` arguments can be passed when
121 | creating a module.
122 |
123 | Example:
124 |
125 | ```python
126 | from dependenpy import Module
127 |
128 | dsm_module = Module("dsm", "path/to/dependenpy/dsm.py")
129 | ```
130 |
131 | ### Create a Dependency
132 |
133 | A dependency is a simple object that require:
134 |
135 | - `source`: the `Module` instance importing the item,
136 | - `lineno`: the line number at which the import occurred,
137 | - `target`: the `Package` or `Module` instance from which the item is imported
138 | - and an optional `what` argument which defaults to None: the name of the
139 | imported item.
140 |
141 | ### Create a Matrix
142 |
143 | From an instance of `DSM` or `Package` called `node`:
144 |
145 | ```python
146 | matrix = node.as_matrix(depth=2)
147 | ```
148 |
149 | From a list of nodes (DSMs, packages or modules):
150 |
151 | ```python
152 | matrix = Matrix(*node_list, depth=2)
153 | ```
154 |
155 | An instance of `Matrix` has a `data` attribute, which is a two-dimensions
156 | array of integers, and a `keys` attribute which is the list of names,
157 | in the same order as rows in data.
158 |
159 | ### Create a TreeMap
160 |
161 | From an instance of `DSM` or `Package` called `node`:
162 |
163 | ```python
164 | treemap = node.as_treemap(depth=2)
165 | ```
166 |
167 | From a list of nodes (DSMs, packages or modules):
168 |
169 | ```python
170 | matrix = TreeMap(*node_list, depth=2)
171 | ```
172 |
173 | An instance of `TreeMap` has a `data` attribute, which is a two-dimensions
174 | array of integers or treemaps, a `keys` attribute which is the list of names
175 | in the same order as rows in data, and a `value` attribute which is the
176 | total number of dependencies in the treemap.
177 |
178 | ### Create a Graph
179 |
180 | From an instance of `DSM` or `Package` called `node`:
181 |
182 | ```python
183 | graph = node.as_graph(depth=2)
184 | ```
185 |
186 | From a list of nodes (DSMs, packages or modules):
187 |
188 | ```python
189 | graph = Graph(*node_list, depth=2)
190 | ```
191 |
192 | An instance of `Graph` has a `vertices` attribute, which is a list of
193 | `Vertex` instances, and a `edges` attribute which is list of `Edge`
194 | instances. See the documentation of `Vertex` and `Edge` for more
195 | information.
196 |
197 | ## Accessing elements
198 |
199 | Accessing elements in a DSM or a Package is very easy. Just like for a
200 | dictionary, you can use the `[]` notation to search for a sub-package or
201 | a sub-module. You can also use the `get` method, which is equivalent to
202 | the brackets accessor, but will return `None` if the element is not found
203 | whereas brackets accessor will raise a `KeyError`.
204 |
205 | Example:
206 |
207 | ```python
208 | from dependenpy import DSM
209 |
210 | dsm = DSM("django") # full DSM object, containing Django
211 | django = dsm["django"] # Django Package object
212 | ```
213 |
214 | You can use dots in the element name to go further in just one instruction:
215 |
216 | ```python
217 | django_auth = django["contrib.auth"]
218 | django_forms_models = dsm.get("django.forms.models")
219 | ```
220 |
221 | Of course, accesses can be chained:
222 |
223 | ```python
224 | django_db_models_utils = dsm["django"].get("db")["models"]["utils"]
225 | ```
226 |
227 | ## Printing contents
228 |
229 | Contents of DSMs, packages, modules, matrices, treemaps and graphs can be printed
230 | with their `print` method. The contents printed are the dependencies.
231 | With some exception, each one of them can output contents in three different formats:
232 |
233 | - text (by default)
234 | - CSV
235 | - JSON
236 |
237 | (Currently, treemaps are not implemented, and graphs can only be printed in
238 | JSON or CSV.)
239 |
240 | To choose one of these format, just pass the `format` argument, which accepts
241 | values `'text'`, `'csv'` and `'json'`. Please note that these values
242 | can be replaced by constants imported from `dependenpy.helpers`
243 | module:
244 |
245 | ```python
246 | from dependenpy import DSM
247 | from dependenpy.helpers import TEXT, CSV, JSON
248 |
249 | dsm = DSM("django")
250 | dsm.print(format=JSON)
251 | ```
252 |
253 | Depending on the chosen format, additional keyword arguments can be passed
254 | to the print method:
255 |
256 | - text format: `indent`, indentation value (integer)
257 | - CSV format: `header`, True or False, to display the headers (columns names)
258 | - JSON format: every arguments accepted by `json.dumps`, and in the case
259 | of a `Module` instance, `absolute` Boolean to switch between output
260 | of absolute and relative paths.
261 |
262 | For `DSM` and `Package` instances, shortcuts to print a matrix, a treemap
263 | or a graph are available with `print_matrix`, `print_treemap` and
264 | `print_graph` methods.
265 | These methods will first create the related object and then call
266 | the object's own `print` method.
267 |
--------------------------------------------------------------------------------
/duties.py:
--------------------------------------------------------------------------------
1 | """Development tasks."""
2 |
3 | from __future__ import annotations
4 |
5 | import os
6 | import sys
7 | from importlib.metadata import version as pkgversion
8 | from pathlib import Path
9 | from typing import TYPE_CHECKING, Any
10 |
11 | from duty import duty
12 | from duty.callables import black, blacken_docs, coverage, lazy, mkdocs, mypy, pytest, ruff, safety
13 |
14 | if TYPE_CHECKING:
15 | from duty.context import Context
16 |
17 |
18 | PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts"))
19 | PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS)
20 | PY_SRC = " ".join(PY_SRC_LIST)
21 | CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""}
22 | WINDOWS = os.name == "nt"
23 | PTY = not WINDOWS and not CI
24 | MULTIRUN = os.environ.get("PDM_MULTIRUN", "0") == "1"
25 |
26 |
27 | def pyprefix(title: str) -> str: # noqa: D103
28 | if MULTIRUN:
29 | prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})"
30 | return f"{prefix:14}{title}"
31 | return title
32 |
33 |
34 | def merge(d1: Any, d2: Any) -> Any: # noqa: D103
35 | basic_types = (int, float, str, bool, complex)
36 | if isinstance(d1, dict) and isinstance(d2, dict):
37 | for key, value in d2.items():
38 | if key in d1:
39 | if isinstance(d1[key], basic_types):
40 | d1[key] = value
41 | else:
42 | d1[key] = merge(d1[key], value)
43 | else:
44 | d1[key] = value
45 | return d1
46 | if isinstance(d1, list) and isinstance(d2, list):
47 | return d1 + d2
48 | return d2
49 |
50 |
51 | def mkdocs_config() -> str: # noqa: D103
52 | import mergedeep
53 |
54 | # force YAML loader to merge arrays
55 | mergedeep.merge = merge
56 |
57 | if "+insiders" in pkgversion("mkdocs-material"):
58 | return "mkdocs.insiders.yml"
59 | return "mkdocs.yml"
60 |
61 |
62 | @duty
63 | def changelog(ctx: Context) -> None:
64 | """Update the changelog in-place with latest commits.
65 |
66 | Parameters:
67 | ctx: The context instance (passed automatically).
68 | """
69 | from git_changelog.cli import build_and_render
70 |
71 | git_changelog = lazy(build_and_render, name="git_changelog")
72 | ctx.run(
73 | git_changelog(
74 | repository=".",
75 | output="CHANGELOG.md",
76 | convention="angular",
77 | template="keepachangelog",
78 | parse_trailers=True,
79 | parse_refs=False,
80 | sections=["build", "deps", "feat", "fix", "refactor"],
81 | bump_latest=True,
82 | in_place=True,
83 | ),
84 | title="Updating changelog",
85 | )
86 |
87 |
88 | @duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"])
89 | def check(ctx: Context) -> None: # noqa: ARG001
90 | """Check it all!
91 |
92 | Parameters:
93 | ctx: The context instance (passed automatically).
94 | """
95 |
96 |
97 | @duty
98 | def check_quality(ctx: Context) -> None:
99 | """Check the code quality.
100 |
101 | Parameters:
102 | ctx: The context instance (passed automatically).
103 | """
104 | ctx.run(
105 | ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
106 | title=pyprefix("Checking code quality"),
107 | command=f"ruff check --config config/ruff.toml {PY_SRC}",
108 | )
109 |
110 |
111 | @duty
112 | def check_dependencies(ctx: Context) -> None:
113 | """Check for vulnerabilities in dependencies.
114 |
115 | Parameters:
116 | ctx: The context instance (passed automatically).
117 | """
118 | # retrieve the list of dependencies
119 | requirements = ctx.run(
120 | ["pdm", "export", "-f", "requirements", "--without-hashes"],
121 | title="Exporting dependencies as requirements",
122 | allow_overrides=False,
123 | )
124 |
125 | ctx.run(
126 | safety.check(requirements),
127 | title="Checking dependencies",
128 | command="pdm export -f requirements --without-hashes | safety check --stdin",
129 | )
130 |
131 |
132 | @duty
133 | def check_docs(ctx: Context) -> None:
134 | """Check if the documentation builds correctly.
135 |
136 | Parameters:
137 | ctx: The context instance (passed automatically).
138 | """
139 | Path("htmlcov").mkdir(parents=True, exist_ok=True)
140 | Path("htmlcov/index.html").touch(exist_ok=True)
141 | config = mkdocs_config()
142 | ctx.run(
143 | mkdocs.build(strict=True, config_file=config, verbose=True),
144 | title=pyprefix("Building documentation"),
145 | command=f"mkdocs build -vsf {config}",
146 | )
147 |
148 |
149 | @duty
150 | def check_types(ctx: Context) -> None:
151 | """Check that the code is correctly typed.
152 |
153 | Parameters:
154 | ctx: The context instance (passed automatically).
155 | """
156 | ctx.run(
157 | mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"),
158 | title=pyprefix("Type-checking"),
159 | command=f"mypy --config-file config/mypy.ini {PY_SRC}",
160 | )
161 |
162 |
163 | @duty
164 | def check_api(ctx: Context) -> None:
165 | """Check for API breaking changes.
166 |
167 | Parameters:
168 | ctx: The context instance (passed automatically).
169 | """
170 | from griffe.cli import check as g_check
171 |
172 | griffe_check = lazy(g_check, name="griffe.check")
173 | ctx.run(
174 | griffe_check("dependenpy", search_paths=["src"], color=True),
175 | title="Checking for API breaking changes",
176 | command="griffe check -ssrc dependenpy",
177 | nofail=True,
178 | )
179 |
180 |
181 | @duty(silent=True)
182 | def clean(ctx: Context) -> None:
183 | """Delete temporary files.
184 |
185 | Parameters:
186 | ctx: The context instance (passed automatically).
187 | """
188 | ctx.run("rm -rf .coverage*")
189 | ctx.run("rm -rf .mypy_cache")
190 | ctx.run("rm -rf .pytest_cache")
191 | ctx.run("rm -rf tests/.pytest_cache")
192 | ctx.run("rm -rf build")
193 | ctx.run("rm -rf dist")
194 | ctx.run("rm -rf htmlcov")
195 | ctx.run("rm -rf pip-wheel-metadata")
196 | ctx.run("rm -rf site")
197 | ctx.run("find . -type d -name __pycache__ | xargs rm -rf")
198 | ctx.run("find . -name '*.rej' -delete")
199 |
200 |
201 | @duty
202 | def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None:
203 | """Serve the documentation (localhost:8000).
204 |
205 | Parameters:
206 | ctx: The context instance (passed automatically).
207 | host: The host to serve the docs from.
208 | port: The port to serve the docs on.
209 | """
210 | ctx.run(
211 | mkdocs.serve(dev_addr=f"{host}:{port}", config_file=mkdocs_config()),
212 | title="Serving documentation",
213 | capture=False,
214 | )
215 |
216 |
217 | @duty
218 | def docs_deploy(ctx: Context) -> None:
219 | """Deploy the documentation on GitHub pages.
220 |
221 | Parameters:
222 | ctx: The context instance (passed automatically).
223 | """
224 | os.environ["DEPLOY"] = "true"
225 | config_file = mkdocs_config()
226 | if config_file == "mkdocs.yml":
227 | ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
228 | ctx.run(mkdocs.gh_deploy(config_file=config_file), title="Deploying documentation")
229 |
230 |
231 | @duty
232 | def format(ctx: Context) -> None:
233 | """Run formatting tools on the code.
234 |
235 | Parameters:
236 | ctx: The context instance (passed automatically).
237 | """
238 | ctx.run(
239 | ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
240 | title="Auto-fixing code",
241 | )
242 | ctx.run(black.run(*PY_SRC_LIST, config="config/black.toml"), title="Formatting code")
243 | ctx.run(
244 | blacken_docs.run(*PY_SRC_LIST, "docs", exts=["py", "md"], line_length=120),
245 | title="Formatting docs",
246 | nofail=True,
247 | )
248 |
249 |
250 | @duty(post=["docs-deploy"])
251 | def release(ctx: Context, version: str) -> None:
252 | """Release a new Python package.
253 |
254 | Parameters:
255 | ctx: The context instance (passed automatically).
256 | version: The new version number to use.
257 | """
258 | ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY)
259 | ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY)
260 | ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY)
261 | ctx.run("git push", title="Pushing commits", pty=False)
262 | ctx.run("git push --tags", title="Pushing tags", pty=False)
263 | ctx.run("pdm build", title="Building dist/wheel", pty=PTY)
264 | ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY)
265 |
266 |
267 | @duty(silent=True, aliases=["coverage"])
268 | def cov(ctx: Context) -> None:
269 | """Report coverage as text and HTML.
270 |
271 | Parameters:
272 | ctx: The context instance (passed automatically).
273 | """
274 | ctx.run(coverage.combine, nofail=True)
275 | ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False)
276 | ctx.run(coverage.html(rcfile="config/coverage.ini"))
277 |
278 |
279 | @duty
280 | def test(ctx: Context, match: str = "") -> None:
281 | """Run the test suite.
282 |
283 | Parameters:
284 | ctx: The context instance (passed automatically).
285 | match: A pytest expression to filter selected tests.
286 | """
287 | py_version = f"{sys.version_info.major}{sys.version_info.minor}"
288 | os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
289 | ctx.run(
290 | pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"),
291 | title=pyprefix("Running tests"),
292 | command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests",
293 | )
294 |
--------------------------------------------------------------------------------
/mkdocs.insiders.yml:
--------------------------------------------------------------------------------
1 | INHERIT: mkdocs.yml
2 |
3 | plugins:
4 | - typeset
5 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: "Dependenpy"
2 | site_description: "Show the inter-dependencies between modules of Python packages."
3 | site_url: "https://pawamoy.github.io/dependenpy"
4 | repo_url: "https://github.com/pawamoy/dependenpy"
5 | repo_name: "pawamoy/dependenpy"
6 | site_dir: "site"
7 | watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/dependenpy]
8 | copyright: Copyright © 2020 Timothée Mazzucotelli
9 | edit_uri: edit/main/docs/
10 |
11 | validation:
12 | omitted_files: warn
13 | absolute_links: warn
14 | unrecognized_links: warn
15 |
16 | nav:
17 | - Home:
18 | - Overview: index.md
19 | - Changelog: changelog.md
20 | - Credits: credits.md
21 | - License: license.md
22 | - Usage: usage.md
23 | # defer to gen-files + literate-nav
24 | - API reference:
25 | - Dependenpy: reference/
26 | - Development:
27 | - Contributing: contributing.md
28 | - Code of Conduct: code_of_conduct.md
29 | - Coverage report: coverage.md
30 | - Author's website: https://pawamoy.github.io/
31 |
32 | theme:
33 | name: material
34 | custom_dir: docs/.overrides
35 | icon:
36 | logo: material/currency-sign
37 | features:
38 | - announce.dismiss
39 | - content.action.edit
40 | - content.action.view
41 | - content.code.annotate
42 | - content.code.copy
43 | - content.tooltips
44 | - navigation.footer
45 | - navigation.indexes
46 | - navigation.sections
47 | - navigation.tabs
48 | - navigation.tabs.sticky
49 | - navigation.top
50 | - search.highlight
51 | - search.suggest
52 | - toc.follow
53 | palette:
54 | - media: "(prefers-color-scheme)"
55 | toggle:
56 | icon: material/brightness-auto
57 | name: Switch to light mode
58 | - media: "(prefers-color-scheme: light)"
59 | scheme: default
60 | primary: teal
61 | accent: purple
62 | toggle:
63 | icon: material/weather-sunny
64 | name: Switch to dark mode
65 | - media: "(prefers-color-scheme: dark)"
66 | scheme: slate
67 | primary: black
68 | accent: lime
69 | toggle:
70 | icon: material/weather-night
71 | name: Switch to system preference
72 |
73 | extra_css:
74 | - css/material.css
75 | - css/mkdocstrings.css
76 |
77 | markdown_extensions:
78 | - attr_list
79 | - admonition
80 | - callouts
81 | - footnotes
82 | - pymdownx.emoji:
83 | emoji_index: !!python/name:materialx.emoji.twemoji
84 | emoji_generator: !!python/name:materialx.emoji.to_svg
85 | - pymdownx.magiclink
86 | - pymdownx.snippets:
87 | check_paths: true
88 | - pymdownx.superfences
89 | - pymdownx.tabbed:
90 | alternate_style: true
91 | slugify: !!python/object/apply:pymdownx.slugs.slugify
92 | kwds:
93 | case: lower
94 | - pymdownx.tasklist:
95 | custom_checkbox: true
96 | - toc:
97 | permalink: "¤"
98 |
99 | plugins:
100 | - search
101 | - markdown-exec
102 | - gen-files:
103 | scripts:
104 | - scripts/gen_ref_nav.py
105 | - literate-nav:
106 | nav_file: SUMMARY.txt
107 | - coverage
108 | - mkdocstrings:
109 | handlers:
110 | python:
111 | import:
112 | - https://docs.python.org/3/objects.inv
113 | paths: [src]
114 | options:
115 | docstring_options:
116 | ignore_init_summary: true
117 | docstring_section_style: list
118 | filters: ["!^_"]
119 | heading_level: 1
120 | inherited_members: true
121 | merge_init_into_class: true
122 | separate_signature: true
123 | show_root_heading: true
124 | show_root_full_path: false
125 | show_signature_annotations: true
126 | show_symbol_type_heading: true
127 | show_symbol_type_toc: true
128 | signature_crossrefs: true
129 | summary: true
130 | - git-committers:
131 | enabled: !ENV [DEPLOY, false]
132 | repository: pawamoy/dependenpy
133 |
134 | - minify:
135 | minify_html: !ENV [DEPLOY, false]
136 |
137 | extra:
138 | social:
139 | - icon: fontawesome/brands/github
140 | link: https://github.com/pawamoy
141 | - icon: fontawesome/brands/mastodon
142 | link: https://fosstodon.org/@pawamoy
143 | - icon: fontawesome/brands/twitter
144 | link: https://twitter.com/pawamoy
145 | - icon: fontawesome/brands/gitter
146 | link: https://gitter.im/dependenpy/community
147 | - icon: fontawesome/brands/python
148 | link: https://pypi.org/project/dependenpy/
149 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["pdm-backend"]
3 | build-backend = "pdm.backend"
4 |
5 | [project]
6 | name = "dependenpy"
7 | description = "Show the inter-dependencies between modules of Python packages."
8 | authors = [{name = "Timothée Mazzucotelli", email = "pawamoy@pm.me"}]
9 | license = {text = "ISC"}
10 | readme = "README.md"
11 | requires-python = ">=3.8"
12 | keywords = ["dependency", "analysis", "matrix", "dsm"]
13 | dynamic = ["version"]
14 | classifiers = [
15 | "Development Status :: 4 - Beta",
16 | "Intended Audience :: Developers",
17 | "Programming Language :: Python",
18 | "Programming Language :: Python :: 3",
19 | "Programming Language :: Python :: 3 :: Only",
20 | "Programming Language :: Python :: 3.8",
21 | "Programming Language :: Python :: 3.9",
22 | "Programming Language :: Python :: 3.10",
23 | "Programming Language :: Python :: 3.11",
24 | "Programming Language :: Python :: 3.12",
25 | "Topic :: Software Development",
26 | "Topic :: Utilities",
27 | "Typing :: Typed",
28 | ]
29 | dependencies = [
30 | "colorama>=0.4.5"
31 | ]
32 |
33 | [project.urls]
34 | Homepage = "https://pawamoy.github.io/dependenpy"
35 | Documentation = "https://pawamoy.github.io/dependenpy"
36 | Changelog = "https://pawamoy.github.io/dependenpy/changelog"
37 | Repository = "https://github.com/pawamoy/dependenpy"
38 | Issues = "https://github.com/pawamoy/dependenpy/issues"
39 | Discussions = "https://github.com/pawamoy/dependenpy/discussions"
40 | Gitter = "https://gitter.im/dependenpy/community"
41 | Funding = "https://github.com/sponsors/pawamoy"
42 |
43 | [project.scripts]
44 | dependenpy = "dependenpy.cli:main"
45 |
46 | [project.entry-points.archan]
47 | "dependenpy.InternalDependencies" = "dependenpy.plugins:InternalDependencies"
48 |
49 | [tool.pdm]
50 | version = {source = "scm"}
51 | plugins = [
52 | "pdm-multirun",
53 | ]
54 |
55 | [tool.pdm.build]
56 | package-dir = "src"
57 | editable-backend = "editables"
58 |
59 | [tool.pdm.dev-dependencies]
60 | duty = ["duty>=0.10"]
61 | ci-quality = ["dependenpy[duty,docs,quality,typing,security]"]
62 | ci-tests = ["dependenpy[duty,tests]"]
63 | docs = [
64 | "black>=23.1",
65 | "markdown-callouts>=0.2",
66 | "markdown-exec>=0.5",
67 | "mkdocs>=1.5",
68 | "mkdocs-coverage>=0.2",
69 | "mkdocs-gen-files>=0.3",
70 | "mkdocs-git-committers-plugin-2>=1.1",
71 | "mkdocs-literate-nav>=0.4",
72 | "mkdocs-material>=7.3",
73 | "mkdocs-minify-plugin>=0.6.4",
74 | "mkdocstrings[python]>=0.18",
75 | "toml>=0.10",
76 | ]
77 | maintain = [
78 | "black>=23.1",
79 | "blacken-docs>=1.13",
80 | "git-changelog>=1.0",
81 | ]
82 | quality = [
83 | "ruff>=0.0.246",
84 | ]
85 | tests = [
86 | "pytest>=6.2",
87 | "pytest-cov>=3.0",
88 | "pytest-randomly>=3.10",
89 | "pytest-xdist>=2.4",
90 | ]
91 | typing = [
92 | "mypy>=0.910",
93 | "types-markdown>=3.3",
94 | "types-pyyaml>=6.0",
95 | "types-toml>=0.10",
96 | ]
97 | security = [
98 | "safety>=2",
99 | ]
100 |
--------------------------------------------------------------------------------
/scripts/gen_credits.py:
--------------------------------------------------------------------------------
1 | """Script to generate the project's credits."""
2 |
3 | from __future__ import annotations
4 |
5 | import re
6 | import sys
7 | from itertools import chain
8 | from pathlib import Path
9 | from textwrap import dedent
10 | from typing import Mapping, cast
11 |
12 | import toml
13 | from jinja2 import StrictUndefined
14 | from jinja2.sandbox import SandboxedEnvironment
15 |
16 | if sys.version_info < (3, 8):
17 | from importlib_metadata import PackageNotFoundError, metadata
18 | else:
19 | from importlib.metadata import PackageNotFoundError, metadata
20 |
21 | project_dir = Path(".")
22 | pyproject = toml.load(project_dir / "pyproject.toml")
23 | project = pyproject["project"]
24 | pdm = pyproject["tool"]["pdm"]
25 | lock_data = toml.load(project_dir / "pdm.lock")
26 | lock_pkgs = {pkg["name"].lower(): pkg for pkg in lock_data["package"]}
27 | project_name = project["name"]
28 | regex = re.compile(r"(?P[\w.-]+)(?P.*)$")
29 |
30 |
31 | def _get_license(pkg_name: str) -> str:
32 | try:
33 | data = metadata(pkg_name)
34 | except PackageNotFoundError:
35 | return "?"
36 | license_name = cast(dict, data).get("License", "").strip()
37 | multiple_lines = bool(license_name.count("\n"))
38 | # TODO: remove author logic once all my packages licenses are fixed
39 | author = ""
40 | if multiple_lines or not license_name or license_name == "UNKNOWN":
41 | for header, value in cast(dict, data).items():
42 | if header == "Classifier" and value.startswith("License ::"):
43 | license_name = value.rsplit("::", 1)[1].strip()
44 | elif header == "Author-email":
45 | author = value
46 | if license_name == "Other/Proprietary License" and "pawamoy" in author:
47 | license_name = "ISC"
48 | return license_name or "?"
49 |
50 |
51 | def _get_deps(base_deps: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, str]]:
52 | deps = {}
53 | for dep in base_deps:
54 | parsed = regex.match(dep).groupdict() # type: ignore[union-attr]
55 | dep_name = parsed["dist"].lower()
56 | if dep_name not in lock_pkgs:
57 | continue
58 | deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]}
59 |
60 | again = True
61 | while again:
62 | again = False
63 | for pkg_name in lock_pkgs:
64 | if pkg_name in deps:
65 | for pkg_dependency in lock_pkgs[pkg_name].get("dependencies", []):
66 | parsed = regex.match(pkg_dependency).groupdict() # type: ignore[union-attr]
67 | dep_name = parsed["dist"].lower()
68 | if dep_name in lock_pkgs and dep_name not in deps and dep_name != project["name"]:
69 | deps[dep_name] = {"license": _get_license(dep_name), **parsed, **lock_pkgs[dep_name]}
70 | again = True
71 |
72 | return deps
73 |
74 |
75 | def _render_credits() -> str:
76 | dev_dependencies = _get_deps(chain(*pdm.get("dev-dependencies", {}).values())) # type: ignore[arg-type]
77 | prod_dependencies = _get_deps(
78 | chain( # type: ignore[arg-type]
79 | project.get("dependencies", []),
80 | chain(*project.get("optional-dependencies", {}).values()),
81 | ),
82 | )
83 |
84 | template_data = {
85 | "project_name": project_name,
86 | "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: dep["name"]),
87 | "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: dep["name"]),
88 | "more_credits": "http://pawamoy.github.io/credits/",
89 | }
90 | template_text = dedent(
91 | """
92 | # Credits
93 |
94 | These projects were used to build *{{ project_name }}*. **Thank you!**
95 |
96 | [`python`](https://www.python.org/) |
97 | [`pdm`](https://pdm.fming.dev/) |
98 | [`copier-pdm`](https://github.com/pawamoy/copier-pdm)
99 |
100 | {% macro dep_line(dep) -%}
101 | [`{{ dep.name }}`](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }}
102 | {%- endmacro %}
103 |
104 | ### Runtime dependencies
105 |
106 | Project | Summary | Version (accepted) | Version (last resolved) | License
107 | ------- | ------- | ------------------ | ----------------------- | -------
108 | {% for dep in prod_dependencies -%}
109 | {{ dep_line(dep) }}
110 | {% endfor %}
111 |
112 | ### Development dependencies
113 |
114 | Project | Summary | Version (accepted) | Version (last resolved) | License
115 | ------- | ------- | ------------------ | ----------------------- | -------
116 | {% for dep in dev_dependencies -%}
117 | {{ dep_line(dep) }}
118 | {% endfor %}
119 |
120 | {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %}
121 | """,
122 | )
123 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined)
124 | return jinja_env.from_string(template_text).render(**template_data)
125 |
126 |
127 | print(_render_credits())
128 |
--------------------------------------------------------------------------------
/scripts/gen_ref_nav.py:
--------------------------------------------------------------------------------
1 | """Generate the code reference pages and navigation."""
2 |
3 | from pathlib import Path
4 |
5 | import mkdocs_gen_files
6 |
7 | nav = mkdocs_gen_files.Nav()
8 | mod_symbol = '
'
9 |
10 | for path in sorted(Path("src").rglob("*.py")):
11 | module_path = path.relative_to("src").with_suffix("")
12 | doc_path = path.relative_to("src").with_suffix(".md")
13 | full_doc_path = Path("reference", doc_path)
14 |
15 | parts = tuple(module_path.parts)
16 |
17 | if parts[-1] == "__init__":
18 | parts = parts[:-1]
19 | doc_path = doc_path.with_name("index.md")
20 | full_doc_path = full_doc_path.with_name("index.md")
21 | elif parts[-1].startswith("_"):
22 | continue
23 |
24 | nav_parts = [f"{mod_symbol} {part}" for part in parts]
25 | nav[tuple(nav_parts)] = doc_path.as_posix()
26 |
27 | with mkdocs_gen_files.open(full_doc_path, "w") as fd:
28 | ident = ".".join(parts)
29 | fd.write(f"::: {ident}")
30 |
31 | mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path)
32 |
33 | with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file:
34 | nav_file.writelines(nav.build_literate_nav())
35 |
--------------------------------------------------------------------------------
/scripts/setup.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 |
4 | if ! command -v pdm &>/dev/null; then
5 | if ! command -v pipx &>/dev/null; then
6 | python3 -m pip install --user pipx
7 | fi
8 | pipx install pdm
9 | fi
10 | if ! pdm self list 2>/dev/null | grep -q pdm-multirun; then
11 | pdm install --plugins
12 | fi
13 |
14 | if [ -n "${PDM_MULTIRUN_VERSIONS}" ]; then
15 | pdm multirun -v pdm install -G:all
16 | else
17 | pdm install -G:all
18 | fi
19 |
--------------------------------------------------------------------------------
/src/dependenpy/__init__.py:
--------------------------------------------------------------------------------
1 | """Dependenpy package.
2 |
3 | Show the inter-dependencies between modules of Python packages.
4 |
5 | With dependenpy you will be able to analyze the internal dependencies in
6 | your Python code, i.e. which module needs which other module. You will then
7 | be able to build a dependency matrix and use it for other purposes.
8 |
9 | If you read this message, you probably want to learn about the library and not the command-line tool:
10 | please refer to the README.md included in this package to get the link to the official documentation.
11 | """
12 |
13 | from __future__ import annotations
14 |
15 | from dependenpy.dsm import DSM, Dependency, Module, Package
16 | from dependenpy.structures import Matrix, TreeMap
17 |
18 | __all__: list[str] = ["DSM", "Dependency", "Matrix", "Module", "Package", "TreeMap"]
19 | __version__ = "3.3.0"
20 |
--------------------------------------------------------------------------------
/src/dependenpy/__main__.py:
--------------------------------------------------------------------------------
1 | """Entry-point module, in case you use `python -m dependenpy`.
2 |
3 | Why does this file exist, and why __main__? For more info, read:
4 |
5 | - https://www.python.org/dev/peps/pep-0338/
6 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m
7 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m
8 | """
9 |
10 | import sys
11 |
12 | from dependenpy.cli import main
13 |
14 | if __name__ == "__main__":
15 | sys.exit(main(sys.argv[1:]))
16 |
--------------------------------------------------------------------------------
/src/dependenpy/cli.py:
--------------------------------------------------------------------------------
1 | """Module that contains the command line application."""
2 |
3 | # Why does this file exist, and why not put this in `__main__`?
4 | #
5 | # You might be tempted to import things from `__main__` later,
6 | # but that will cause problems: the code will get executed twice:
7 | #
8 | # - When you run `python -m dependenpy` python will execute
9 | # `__main__.py` as a script. That means there won't be any
10 | # `dependenpy.__main__` in `sys.modules`.
11 | # - When you import `__main__` it will get executed again (as a module) because
12 | # there's no `dependenpy.__main__` in `sys.modules`.
13 |
14 | from __future__ import annotations
15 |
16 | import argparse
17 | import sys
18 | from contextlib import contextmanager
19 | from typing import Iterator, Sequence, TextIO
20 |
21 | from colorama import init
22 |
23 | from dependenpy import __version__
24 | from dependenpy.dsm import DSM
25 | from dependenpy.helpers import CSV, FORMAT, JSON, guess_depth
26 |
27 |
28 | def get_parser() -> argparse.ArgumentParser:
29 | """Return the CLI argument parser.
30 |
31 | Returns:
32 | An argparse parser.
33 | """
34 | parser = argparse.ArgumentParser(
35 | prog="dependenpy",
36 | add_help=False,
37 | description="Command line tool for dependenpy Python package.",
38 | )
39 | mxg = parser.add_mutually_exclusive_group(required=False)
40 |
41 | parser.add_argument(
42 | "packages",
43 | metavar="PACKAGES",
44 | nargs=argparse.ONE_OR_MORE,
45 | help="The package list. Can be a comma-separated list. Each package "
46 | "must be either a valid path or a package in PYTHONPATH.",
47 | )
48 | parser.add_argument(
49 | "-d",
50 | "--depth",
51 | default=None,
52 | type=int,
53 | dest="depth",
54 | help="Specify matrix or graph depth. Default: best guess.",
55 | )
56 | parser.add_argument(
57 | "-f",
58 | "--format",
59 | choices=FORMAT,
60 | default="text",
61 | dest="format",
62 | help="Output format. Default: text.",
63 | )
64 | mxg.add_argument(
65 | "-g",
66 | "--show-graph",
67 | action="store_true",
68 | dest="graph",
69 | default=False,
70 | help="Show the graph (no text format). Default: false.",
71 | )
72 | parser.add_argument(
73 | "-G",
74 | "--greedy",
75 | action="store_true",
76 | dest="greedy",
77 | default=False,
78 | help="Explore subdirectories even if they do not contain an "
79 | "__init__.py file. Can make execution slower. Default: false.",
80 | )
81 | parser.add_argument(
82 | "-h",
83 | "--help",
84 | action="help",
85 | default=argparse.SUPPRESS,
86 | help="Show this help message and exit.",
87 | )
88 | parser.add_argument(
89 | "-i",
90 | "--indent",
91 | default=None,
92 | type=int,
93 | dest="indent",
94 | help="Specify output indentation. CSV will never be indented. "
95 | "Text will always have new-lines. JSON can be minified with "
96 | "a negative value. Default: best guess.",
97 | )
98 | mxg.add_argument(
99 | "-l",
100 | "--show-dependencies-list",
101 | action="store_true",
102 | dest="dependencies",
103 | default=False,
104 | help="Show the dependencies list. Default: false.",
105 | )
106 | mxg.add_argument(
107 | "-m",
108 | "--show-matrix",
109 | action="store_true",
110 | dest="matrix",
111 | default=False,
112 | help="Show the matrix. Default: true unless -g, -l or -t.",
113 | )
114 | parser.add_argument(
115 | "-o",
116 | "--output",
117 | action="store",
118 | dest="output",
119 | default=sys.stdout,
120 | help="Output to given file. Default: stdout.",
121 | )
122 | mxg.add_argument(
123 | "-t",
124 | "--show-treemap",
125 | action="store_true",
126 | dest="treemap",
127 | default=False,
128 | help="Show the treemap (work in progress). Default: false.",
129 | )
130 | parser.add_argument(
131 | "-v",
132 | "--version",
133 | action="version",
134 | version=f"dependenpy {__version__}",
135 | help="Show the current version of the program and exit.",
136 | )
137 | parser.add_argument(
138 | "-z",
139 | "--zero",
140 | dest="zero",
141 | default="0",
142 | help="Character to use for cells with value=0 (text matrix display only).",
143 | )
144 |
145 | return parser
146 |
147 |
148 | @contextmanager
149 | def _open_if_str(output: str | TextIO) -> Iterator[TextIO]:
150 | if isinstance(output, str):
151 | with open(output, "w") as fd:
152 | yield fd
153 | else:
154 | yield output
155 |
156 |
157 | def _get_indent(opts: argparse.Namespace) -> int | None:
158 | if opts.indent is None:
159 | if opts.format == CSV:
160 | return 0
161 | return 2
162 | if opts.indent < 0 and opts.format == JSON:
163 | # special case for json.dumps indent argument
164 | return None
165 | return opts.indent
166 |
167 |
168 | def _get_depth(opts: argparse.Namespace, packages: Sequence[str]) -> int:
169 | return opts.depth or guess_depth(packages)
170 |
171 |
172 | def _get_packages(opts: argparse.Namespace) -> list[str]:
173 | packages = []
174 | for arg in opts.packages:
175 | if "," in arg:
176 | for package in arg.split(","):
177 | if package not in packages:
178 | packages.append(package)
179 | elif arg not in packages:
180 | packages.append(arg)
181 | return packages
182 |
183 |
184 | def _run(opts: argparse.Namespace, dsm: DSM) -> None:
185 | indent = _get_indent(opts)
186 | depth = _get_depth(opts, packages=dsm.base_packages)
187 | with _open_if_str(opts.output) as output:
188 | if opts.dependencies:
189 | dsm.print(format=opts.format, output=output, indent=indent)
190 | elif opts.matrix:
191 | dsm.print_matrix(format=opts.format, output=output, depth=depth, indent=indent, zero=opts.zero)
192 | elif opts.treemap:
193 | dsm.print_treemap(format=opts.format, output=output)
194 | elif opts.graph:
195 | dsm.print_graph(format=opts.format, output=output, depth=depth, indent=indent)
196 |
197 |
198 | def main(args: list[str] | None = None) -> int:
199 | """Run the main program.
200 |
201 | This function is executed when you type `dependenpy` or `python -m dependenpy`.
202 |
203 | Parameters:
204 | args: Arguments passed from the command line.
205 |
206 | Returns:
207 | An exit code. 0 (OK), 1 (dsm empty) or 2 (error).
208 | """
209 | parser = get_parser()
210 | opts = parser.parse_args(args=args)
211 | if not (opts.matrix or opts.dependencies or opts.treemap or opts.graph):
212 | opts.matrix = True
213 |
214 | dsm = DSM(*_get_packages(opts), build_tree=True, build_dependencies=True, enforce_init=not opts.greedy)
215 | if dsm.empty:
216 | return 1
217 |
218 | # init colorama
219 | init()
220 |
221 | try:
222 | _run(opts, dsm)
223 | except BrokenPipeError:
224 | # avoid traceback
225 | return 2
226 |
227 | return 0
228 |
--------------------------------------------------------------------------------
/src/dependenpy/dsm.py:
--------------------------------------------------------------------------------
1 | """dependenpy dsm module.
2 |
3 | This is the core module of dependenpy. It contains the following classes:
4 |
5 | - [`DSM`][dependenpy.dsm.DSM]: to create a DSM-capable object for a list of packages,
6 | - [`Package`][dependenpy.dsm.Package]: which represents a Python package,
7 | - [`Module`][dependenpy.dsm.Module]: which represents a Python module,
8 | - [`Dependency`][dependenpy.dsm.Dependency]: which represents a dependency between two modules.
9 | """
10 |
11 | from __future__ import annotations
12 |
13 | import ast
14 | import json
15 | import sys
16 | from os import listdir
17 | from os.path import isdir, isfile, join, splitext
18 | from pathlib import Path
19 | from typing import Any, Sequence
20 |
21 | from dependenpy.finder import Finder, PackageSpec
22 | from dependenpy.helpers import PrintMixin
23 | from dependenpy.node import LeafNode, NodeMixin, RootNode
24 |
25 |
26 | class DSM(RootNode, NodeMixin, PrintMixin):
27 | """DSM-capable class.
28 |
29 | Technically speaking, a DSM instance is not a real DSM but more a tree
30 | representing the Python packages structure. However, it has the
31 | necessary methods to build a real DSM in the form of a square matrix,
32 | a dictionary or a tree-map.
33 | """
34 |
35 | def __init__(
36 | self,
37 | *packages: str,
38 | build_tree: bool = True,
39 | build_dependencies: bool = True,
40 | enforce_init: bool = True,
41 | ):
42 | """Initialization method.
43 |
44 | Args:
45 | *packages: list of packages to search for.
46 | build_tree: auto-build the tree or not.
47 | build_dependencies: auto-build the dependencies or not.
48 | enforce_init: if True, only treat directories if they contain an `__init__.py` file.
49 | """
50 | self.base_packages = packages
51 | self.finder = Finder()
52 | self.specs = []
53 | self.not_found = []
54 | self.enforce_init = enforce_init
55 |
56 | specs = []
57 | for package in packages:
58 | spec = self.finder.find(package, enforce_init=enforce_init)
59 | if spec:
60 | specs.append(spec)
61 | else:
62 | self.not_found.append(package)
63 |
64 | if not specs:
65 | print("** dependenpy: DSM empty.", file=sys.stderr) # noqa: T201
66 |
67 | self.specs = PackageSpec.combine(specs)
68 |
69 | for module in self.not_found:
70 | print(f"** dependenpy: Not found: {module}.", file=sys.stderr) # noqa: T201
71 |
72 | super().__init__(build_tree)
73 |
74 | if build_tree and build_dependencies:
75 | self.build_dependencies()
76 |
77 | def __str__(self):
78 | packages_names = ", ".join([package.name for package in self.packages])
79 | return f"Dependency DSM for packages: [{packages_names}]"
80 |
81 | @property
82 | def isdsm(self) -> bool:
83 | """Inherited from NodeMixin. Always True.
84 |
85 | Returns:
86 | Whether this object is a DSM.
87 | """
88 | return True
89 |
90 | def build_tree(self) -> None:
91 | """Build the Python packages tree."""
92 | for spec in self.specs:
93 | if spec.ismodule:
94 | self.modules.append(Module(spec.name, spec.path, dsm=self))
95 | else:
96 | self.packages.append(
97 | Package(
98 | spec.name,
99 | spec.path,
100 | dsm=self,
101 | limit_to=spec.limit_to,
102 | build_tree=True,
103 | build_dependencies=False,
104 | enforce_init=self.enforce_init,
105 | ),
106 | )
107 |
108 |
109 | class Package(RootNode, LeafNode, NodeMixin, PrintMixin):
110 | """Package class.
111 |
112 | This class represent Python packages as nodes in a tree.
113 | """
114 |
115 | def __init__(
116 | self,
117 | name: str,
118 | path: str,
119 | dsm: DSM | None = None,
120 | package: Package | None = None,
121 | limit_to: list[str] | None = None,
122 | build_tree: bool = True, # noqa: FBT001,FBT002
123 | build_dependencies: bool = True, # noqa: FBT001,FBT002
124 | enforce_init: bool = True, # noqa: FBT001,FBT002
125 | ):
126 | """Initialization method.
127 |
128 | Args:
129 | name: name of the package.
130 | path: path to the package.
131 | dsm: parent DSM.
132 | package: parent package.
133 | limit_to: list of string to limit the recursive tree-building to what is specified.
134 | build_tree: auto-build the tree or not.
135 | build_dependencies: auto-build the dependencies or not.
136 | enforce_init: if True, only treat directories if they contain an `__init__.py` file.
137 | """
138 | self.name = name
139 | self.path = path
140 | self.package = package
141 | self.dsm = dsm
142 | self.limit_to = limit_to or []
143 | self.enforce_init = enforce_init
144 |
145 | RootNode.__init__(self, build_tree)
146 | LeafNode.__init__(self)
147 |
148 | if build_tree and build_dependencies:
149 | self.build_dependencies()
150 |
151 | @property
152 | def ispackage(self) -> bool:
153 | """Inherited from NodeMixin. Always True.
154 |
155 | Returns:
156 | Whether this object is a package.
157 | """
158 | return True
159 |
160 | @property
161 | def issubpackage(self) -> bool:
162 | """Property to tell if this node is a sub-package.
163 |
164 | Returns:
165 | This package has a parent.
166 | """
167 | return self.package is not None
168 |
169 | @property
170 | def isroot(self) -> bool:
171 | """Property to tell if this node is a root node.
172 |
173 | Returns:
174 | This package has no parent.
175 | """
176 | return self.package is None
177 |
178 | def split_limits_heads(self) -> tuple[list[str], list[str]]:
179 | """Return first parts of dot-separated strings, and rest of strings.
180 |
181 | Returns:
182 | The heads and rest of the strings.
183 | """
184 | heads = []
185 | new_limit_to = []
186 | for limit in self.limit_to:
187 | if "." in limit:
188 | name, limit = limit.split(".", 1) # noqa: PLW2901
189 | heads.append(name)
190 | new_limit_to.append(limit)
191 | else:
192 | heads.append(limit)
193 | return heads, new_limit_to
194 |
195 | def build_tree(self) -> None:
196 | """Build the tree for this package."""
197 | for module in listdir(self.path):
198 | abs_m = join(self.path, module)
199 | if isfile(abs_m) and module.endswith(".py"):
200 | name = splitext(module)[0]
201 | if not self.limit_to or name in self.limit_to:
202 | self.modules.append(Module(name, abs_m, self.dsm, self))
203 | elif isdir(abs_m) and (isfile(join(abs_m, "__init__.py")) or not self.enforce_init):
204 | heads, new_limit_to = self.split_limits_heads()
205 | if not heads or module in heads:
206 | self.packages.append(
207 | Package(
208 | module,
209 | abs_m,
210 | self.dsm,
211 | self,
212 | new_limit_to,
213 | build_tree=True,
214 | build_dependencies=False,
215 | enforce_init=self.enforce_init,
216 | ),
217 | )
218 |
219 | def cardinal(self, to: Package | Module) -> int:
220 | """Return the number of dependencies of this package to the given node.
221 |
222 | Args:
223 | to (Package/Module): target node.
224 |
225 | Returns:
226 | Number of dependencies.
227 | """
228 | return sum(module.cardinal(to) for module in self.submodules)
229 |
230 |
231 | class Module(LeafNode, NodeMixin, PrintMixin):
232 | """Module class.
233 |
234 | This class represents a Python module (a Python file).
235 | """
236 |
237 | RECURSIVE_NODES = (ast.ClassDef, ast.FunctionDef, ast.If, ast.IfExp, ast.Try, ast.With, ast.ExceptHandler)
238 |
239 | def __init__(self, name: str, path: str, dsm: DSM | None = None, package: Package | None = None) -> None:
240 | """Initialization method.
241 |
242 | Args:
243 | name (str): name of the module.
244 | path (str): path to the module.
245 | dsm (DSM): parent DSM.
246 | package (Package): parent Package.
247 | """
248 | super().__init__()
249 | self.name = name
250 | self.path = path
251 | self.package = package
252 | self.dsm = dsm
253 | self.dependencies: list[Dependency] = []
254 |
255 | def __contains__(self, item: Package | Module) -> bool:
256 | """Whether given item is contained inside this module.
257 |
258 | Args:
259 | item (Package/Module): a package or module.
260 |
261 | Returns:
262 | True if self is item or item is self's package and
263 | self if an `__init__` module.
264 | """
265 | if self is item:
266 | return True
267 | if self.package is item and self.name == "__init__":
268 | return True
269 | return False
270 |
271 | @property
272 | def ismodule(self) -> bool:
273 | """Inherited from NodeMixin. Always True.
274 |
275 | Returns:
276 | Whether this object is a module.
277 | """
278 | return True
279 |
280 | def as_dict(self, absolute: bool = False) -> dict: # noqa: FBT001,FBT002
281 | """Return the dependencies as a dictionary.
282 |
283 | Arguments:
284 | absolute: Whether to use the absolute name.
285 |
286 | Returns:
287 | dict: dictionary of dependencies.
288 | """
289 | return {
290 | "name": self.absolute_name() if absolute else self.name,
291 | "path": self.path,
292 | "dependencies": [
293 | {
294 | # 'source': d.source.absolute_name(), # redundant
295 | "target": dep.target if dep.external else dep.target.absolute_name(), # type: ignore[union-attr]
296 | "lineno": dep.lineno,
297 | "what": dep.what,
298 | "external": dep.external,
299 | }
300 | for dep in self.dependencies
301 | ],
302 | }
303 |
304 | def _to_text(self, **kwargs: Any) -> str:
305 | indent = kwargs.pop("indent", 2)
306 | base_indent = kwargs.pop("base_indent", None)
307 | if base_indent is None:
308 | base_indent = indent
309 | indent = 0
310 | text = [" " * indent + self.name + "\n"]
311 | new_indent = indent + base_indent
312 | for dep in self.dependencies:
313 | external = "! " if dep.external else ""
314 | text.append(" " * new_indent + external + str(dep) + "\n")
315 | return "".join(text)
316 |
317 | def _to_csv(self, **kwargs: Any) -> str:
318 | header = kwargs.pop("header", True)
319 | text = ["module,path,target,lineno,what,external\n" if header else ""]
320 | name = self.absolute_name()
321 | for dep in self.dependencies:
322 | target = dep.target if dep.external else dep.target.absolute_name() # type: ignore[union-attr]
323 | text.append(f"{name},{self.path},{target},{dep.lineno},{dep.what or ''},{dep.external}\n")
324 | return "".join(text)
325 |
326 | def _to_json(self, **kwargs: Any) -> str:
327 | absolute = kwargs.pop("absolute", False)
328 | return json.dumps(self.as_dict(absolute=absolute), **kwargs)
329 |
330 | def build_dependencies(self) -> None:
331 | """Build the dependencies for this module.
332 |
333 | Parse the code with ast, find all the import statements, convert
334 | them into Dependency objects.
335 | """
336 | highest = self.dsm or self.root
337 | for import_ in self.parse_code():
338 | target = highest.get_target(import_["target"])
339 | if target:
340 | what = import_["target"].split(".")[-1]
341 | if what != target.name:
342 | import_["what"] = what
343 | import_["target"] = target
344 | self.dependencies.append(Dependency(source=self, **import_))
345 |
346 | def parse_code(self) -> list[dict]:
347 | """Read the source code and return all the import statements.
348 |
349 | Returns:
350 | list of dict: the import statements.
351 | """
352 | code = Path(self.path).read_text(encoding="utf-8")
353 | try:
354 | body = ast.parse(code).body
355 | except SyntaxError:
356 | code = code.encode("utf-8") # type: ignore[assignment]
357 | try:
358 | body = ast.parse(code).body
359 | except SyntaxError:
360 | return []
361 | return self.get_imports(body)
362 |
363 | def get_imports(self, ast_body: Sequence[ast.AST]) -> list[dict]:
364 | """Return all the import statements given an AST body (AST nodes).
365 |
366 | Args:
367 | ast_body (compiled code's body): the body to filter.
368 |
369 | Returns:
370 | The import statements.
371 | """
372 | imports: list[dict] = []
373 | for node in ast_body:
374 | if isinstance(node, ast.Import):
375 | imports.extend({"target": name.name, "lineno": node.lineno} for name in node.names)
376 | elif isinstance(node, ast.ImportFrom):
377 | for name in node.names:
378 | abs_name = self.absolute_name(self.depth - node.level) + "." if node.level > 0 else ""
379 | node_module = node.module + "." if node.module else ""
380 | name = abs_name + node_module + name.name # type: ignore[assignment] # noqa: PLW2901
381 | imports.append({"target": name, "lineno": node.lineno})
382 | elif isinstance(node, Module.RECURSIVE_NODES):
383 | imports.extend(self.get_imports(node.body)) # type: ignore[arg-type]
384 | if isinstance(node, ast.Try):
385 | imports.extend(self.get_imports(node.finalbody))
386 | return imports
387 |
388 | def cardinal(self, to: Package | Module) -> int:
389 | """Return the number of dependencies of this module to the given node.
390 |
391 | Args:
392 | to (Package/Module): the target node.
393 |
394 | Returns:
395 | Number of dependencies.
396 | """
397 | return len([dep for dep in self.dependencies if not dep.external and dep.target in to]) # type: ignore[operator]
398 |
399 |
400 | class Dependency:
401 | """Dependency class.
402 |
403 | Represent a dependency from a module to another.
404 | """
405 |
406 | def __init__(self, source: Module, lineno: int, target: str | Module | Package, what: str | None = None) -> None:
407 | """Initialization method.
408 |
409 | Args:
410 | source (Module): source Module.
411 | lineno (int): number of line at which import statement occurs.
412 | target (str/Module/Package): the target node.
413 | what (str): what is imported (optional).
414 | """
415 | self.source = source
416 | self.lineno = lineno
417 | self.target = target
418 | self.what = what
419 |
420 | def __str__(self):
421 | what = f"{self.what or ''} from "
422 | target = self.target if self.external else self.target.absolute_name()
423 | return f"{self.source.name} imports {what}{target} (line {self.lineno})"
424 |
425 | @property
426 | def external(self) -> bool:
427 | """Property to tell if the dependency's target is a valid node.
428 |
429 | Returns:
430 | Whether the dependency's target is a valid node.
431 | """
432 | return isinstance(self.target, str)
433 |
--------------------------------------------------------------------------------
/src/dependenpy/finder.py:
--------------------------------------------------------------------------------
1 | """dependenpy finder module."""
2 |
3 | from __future__ import annotations
4 |
5 | from importlib.util import find_spec
6 | from os.path import basename, exists, isdir, isfile, join, splitext
7 | from typing import Any
8 |
9 |
10 | class PackageSpec:
11 | """Holder for a package specification (given as argument to DSM)."""
12 |
13 | def __init__(self, name: str, path: str, limit_to: list[str] | None = None) -> None:
14 | """Initialization method.
15 |
16 | Args:
17 | name (str): name of the package.
18 | path (str): path to the package.
19 | limit_to (list of str): limitations.
20 | """
21 | self.name = name
22 | self.path = path
23 | self.limit_to = limit_to or []
24 |
25 | def __hash__(self):
26 | return hash((self.name, self.path))
27 |
28 | @property
29 | def ismodule(self) -> bool:
30 | """Property to tell if the package is in fact a module (a file).
31 |
32 | Returns:
33 | Whether this package is in fact a module.
34 | """
35 | return self.path.endswith(".py")
36 |
37 | def add(self, spec: PackageSpec) -> None:
38 | """Add limitations of given spec to self's.
39 |
40 | Args:
41 | spec: Another spec.
42 | """
43 | for limit in spec.limit_to:
44 | if limit not in self.limit_to:
45 | self.limit_to.append(limit)
46 |
47 | @staticmethod
48 | def combine(specs: list[PackageSpec]) -> list[PackageSpec]:
49 | """Combine package specifications' limitations.
50 |
51 | Args:
52 | specs: The package specifications.
53 |
54 | Returns:
55 | The new, merged list of PackageSpec.
56 | """
57 | new_specs: dict[PackageSpec, PackageSpec] = {}
58 | for spec in specs:
59 | if new_specs.get(spec, None) is None:
60 | new_specs[spec] = spec
61 | else:
62 | new_specs[spec].add(spec)
63 | return list(new_specs.values())
64 |
65 |
66 | class PackageFinder:
67 | """Abstract package finder class."""
68 |
69 | def find(self, package: str, **kwargs: Any) -> PackageSpec | None:
70 | """Find method.
71 |
72 | Args:
73 | package: package to find.
74 | **kwargs: additional keyword arguments.
75 |
76 | Returns:
77 | Package spec or None.
78 | """
79 | raise NotImplementedError
80 |
81 |
82 | class LocalPackageFinder(PackageFinder):
83 | """Finder to find local packages (directories on the disk)."""
84 |
85 | def find(self, package: str, **kwargs: Any) -> PackageSpec | None:
86 | """Find method.
87 |
88 | Args:
89 | package: package to find.
90 | **kwargs: additional keyword arguments.
91 |
92 | Returns:
93 | Package spec or None.
94 | """
95 | if not exists(package):
96 | return None
97 | name, path = None, None
98 | enforce_init = kwargs.pop("enforce_init", True)
99 | if isdir(package):
100 | if isfile(join(package, "__init__.py")) or not enforce_init:
101 | name, path = basename(package), package
102 | elif isfile(package) and package.endswith(".py"):
103 | name, path = splitext(basename(package))[0], package
104 | if name and path:
105 | return PackageSpec(name, path)
106 | return None
107 |
108 |
109 | class InstalledPackageFinder(PackageFinder):
110 | """Finder to find installed Python packages using importlib."""
111 |
112 | def find(self, package: str, **kwargs: Any) -> PackageSpec | None: # noqa: ARG002
113 | """Find method.
114 |
115 | Args:
116 | package: package to find.
117 | **kwargs: additional keyword arguments.
118 |
119 | Returns:
120 | Package spec or None.
121 | """
122 | spec = find_spec(package)
123 | if spec is None:
124 | return None
125 | if "." in package:
126 | package, rest = package.split(".", 1)
127 | limit = [rest]
128 | spec = find_spec(package)
129 | else:
130 | limit = []
131 | if spec is not None:
132 | if spec.submodule_search_locations:
133 | path = spec.submodule_search_locations[0]
134 | elif spec.origin and spec.origin != "built-in":
135 | path = spec.origin
136 | else:
137 | return None
138 | return PackageSpec(spec.name, path, limit)
139 | return None
140 |
141 |
142 | class Finder:
143 | """Main package finder class.
144 |
145 | Initialize it with a list of package finder classes (not instances).
146 | """
147 |
148 | def __init__(self, finders: list[type] | None = None):
149 | """Initialization method.
150 |
151 | Args:
152 | finders: list of package finder classes (not instances) in a specific
153 | order. Default: [LocalPackageFinder, InstalledPackageFinder].
154 | """
155 | if finders is None:
156 | self.finders = [LocalPackageFinder(), InstalledPackageFinder()]
157 | else:
158 | self.finders = [finder() for finder in finders]
159 |
160 | def find(self, package: str, **kwargs: Any) -> PackageSpec | None:
161 | """Find a package using package finders.
162 |
163 | Return the first package found.
164 |
165 | Args:
166 | package: package to find.
167 | **kwargs: additional keyword arguments used by finders.
168 |
169 | Returns:
170 | Package spec or None.
171 | """
172 | for finder in self.finders:
173 | package_spec = finder.find(package, **kwargs)
174 | if package_spec:
175 | return package_spec
176 | return None
177 |
--------------------------------------------------------------------------------
/src/dependenpy/helpers.py:
--------------------------------------------------------------------------------
1 | """dependenpy printer module."""
2 |
3 | from __future__ import annotations
4 |
5 | import sys
6 | from typing import IO, Any, Sequence
7 |
8 | CSV = "csv"
9 | JSON = "json"
10 | TEXT = "text"
11 | FORMAT = (CSV, JSON, TEXT)
12 |
13 |
14 | class PrintMixin:
15 | """Print mixin class."""
16 |
17 | def print(self, format: str | None = TEXT, output: IO = sys.stdout, **kwargs: Any) -> None: # noqa: A002,A003
18 | """Print the object in a file or on standard output by default.
19 |
20 | Args:
21 | format: output format (csv, json or text).
22 | output: descriptor to an opened file (default to standard output).
23 | **kwargs: additional arguments.
24 | """
25 | if format is None:
26 | format = TEXT
27 |
28 | if format != TEXT:
29 | kwargs.pop("zero", "")
30 |
31 | if format == TEXT:
32 | print(self._to_text(**kwargs), file=output)
33 | elif format == CSV:
34 | print(self._to_csv(**kwargs), file=output)
35 | elif format == JSON:
36 | print(self._to_json(**kwargs), file=output)
37 |
38 | def _to_text(self, **kwargs: Any) -> str:
39 | raise NotImplementedError
40 |
41 | def _to_csv(self, **kwargs: Any) -> str:
42 | raise NotImplementedError
43 |
44 | def _to_json(self, **kwargs: Any) -> str:
45 | raise NotImplementedError
46 |
47 |
48 | def guess_depth(packages: Sequence[str]) -> int:
49 | """Guess the optimal depth to use for the given list of arguments.
50 |
51 | Args:
52 | packages: List of packages.
53 |
54 | Returns:
55 | Guessed depth to use.
56 | """
57 | if len(packages) == 1:
58 | return packages[0].count(".") + 2
59 | return min(package.count(".") for package in packages) + 1
60 |
--------------------------------------------------------------------------------
/src/dependenpy/node.py:
--------------------------------------------------------------------------------
1 | """dependenpy node module."""
2 |
3 | from __future__ import annotations
4 |
5 | import json
6 | import sys
7 | from typing import IO, TYPE_CHECKING, Any
8 |
9 | from dependenpy.structures import Graph, Matrix, TreeMap
10 |
11 | if TYPE_CHECKING:
12 | from dependenpy.dsm import Module, Package
13 |
14 |
15 | class NodeMixin:
16 | """Shared code between DSM, Package and Module."""
17 |
18 | @property
19 | def ismodule(self) -> bool:
20 | """Property to check if object is instance of Module.
21 |
22 | Returns:
23 | Whether this object is a module.
24 | """
25 | return False
26 |
27 | @property
28 | def ispackage(self) -> bool:
29 | """Property to check if object is instance of Package.
30 |
31 | Returns:
32 | Whether this object is a package.
33 | """
34 | return False
35 |
36 | @property
37 | def isdsm(self) -> bool:
38 | """Property to check if object is instance of DSM.
39 |
40 | Returns:
41 | Whether this object is a DSM.
42 | """
43 | return False
44 |
45 |
46 | class RootNode:
47 | """Shared code between DSM and Package."""
48 |
49 | def __init__(self, build_tree: bool = True): # noqa: FBT001,FBT002
50 | """Initialization method.
51 |
52 | Args:
53 | build_tree (bool): whether to immediately build the tree or not.
54 | """
55 | self._target_cache: dict[str, Any] = {}
56 | self._item_cache: dict[str, Any] = {}
57 | self._contains_cache: dict[Package | Module, bool] = {}
58 | self._matrix_cache: dict[int, Matrix] = {}
59 | self._graph_cache: dict[int, Graph] = {}
60 | self._treemap_cache = TreeMap()
61 | self.modules: list[Module] = []
62 | self.packages: list[Package] = []
63 |
64 | if build_tree:
65 | self.build_tree()
66 |
67 | def __contains__(self, item: Package | Module) -> bool:
68 | """Get result of _contains, cache it and return it.
69 |
70 | Args:
71 | item: A package or module.
72 |
73 | Returns:
74 | True if self contains item, False otherwise.
75 | """
76 | if item not in self._contains_cache:
77 | self._contains_cache[item] = self._contains(item)
78 | return self._contains_cache[item]
79 |
80 | def __getitem__(self, item: str) -> Package | Module:
81 | """Return the corresponding Package or Module object.
82 |
83 | Args:
84 | item: Name of the package/module, dot-separated.
85 |
86 | Raises:
87 | KeyError: When the package or module cannot be found.
88 |
89 | Returns:
90 | The corresponding object.
91 | """
92 | depth = item.count(".") + 1
93 | parts = item.split(".", 1)
94 | for module in self.modules:
95 | if parts[0] == module.name and depth == 1:
96 | return module
97 | for package in self.packages:
98 | if parts[0] == package.name:
99 | if depth == 1:
100 | return package
101 | obj = package.get(parts[1])
102 | if obj:
103 | return obj
104 | raise KeyError(item)
105 |
106 | def __bool__(self) -> bool:
107 | """Node as Boolean.
108 |
109 | Returns:
110 | Result of node.empty.
111 |
112 | """
113 | return bool(self.modules or self.packages)
114 |
115 | @property
116 | def empty(self) -> bool:
117 | """Whether the node has neither modules nor packages.
118 |
119 | Returns:
120 | True if empty, False otherwise.
121 | """
122 | return not bool(self)
123 |
124 | @property
125 | def submodules(self) -> list[Module]:
126 | """Property to return all sub-modules of the node, recursively.
127 |
128 | Returns:
129 | The sub-modules.
130 | """
131 | submodules = []
132 | submodules.extend(self.modules)
133 | for package in self.packages:
134 | submodules.extend(package.submodules)
135 | return submodules
136 |
137 | def build_tree(self) -> None:
138 | """To be overridden."""
139 | raise NotImplementedError
140 |
141 | def _contains(self, item: Package | Module) -> bool:
142 | """Whether given item is contained inside the node modules/packages.
143 |
144 | Args:
145 | item (Package/Module): a package or module.
146 |
147 | Returns:
148 | bool: True if self is item or item in self's packages/modules.
149 | """
150 | if self is item:
151 | return True
152 | for module in self.modules:
153 | if item in module:
154 | return True
155 | return any(item in package for package in self.packages)
156 |
157 | def get(self, item: str) -> Package | Module:
158 | """Get item through `__getitem__` and cache the result.
159 |
160 | Args:
161 | item: Name of package or module.
162 |
163 | Returns:
164 | The corresponding object.
165 | """
166 | if item not in self._item_cache:
167 | try:
168 | obj = self.__getitem__(item)
169 | except KeyError:
170 | obj = None
171 | self._item_cache[item] = obj
172 | return self._item_cache[item]
173 |
174 | def get_target(self, target: str) -> Package | Module:
175 | """Get the result of _get_target, cache it and return it.
176 |
177 | Args:
178 | target: Target to find.
179 |
180 | Returns:
181 | Package containing target or corresponding module.
182 | """
183 | if target not in self._target_cache:
184 | self._target_cache[target] = self._get_target(target)
185 | return self._target_cache[target]
186 |
187 | def _get_target(self, target: str) -> Package | Module | None:
188 | """Get the Package or Module related to given target.
189 |
190 | Args:
191 | target (str): target to find.
192 |
193 | Returns:
194 | Package/Module: package containing target or corresponding module.
195 | """
196 | depth = target.count(".") + 1
197 | parts = target.split(".", 1)
198 | for module in self.modules:
199 | if parts[0] == module.name and depth < 3: # noqa: PLR2004
200 | return module
201 | for package in self.packages:
202 | if parts[0] == package.name:
203 | if depth == 1:
204 | return package
205 | targ = package._get_target(parts[1])
206 | if targ:
207 | return targ
208 | # FIXME: can lead to internal dep instead of external
209 | # see example with django.contrib.auth.forms
210 | # importing forms from django
211 | # Idea: when parsing files with ast, record what objects
212 | # are defined in the module. Then check here if the given
213 | # part is one of these objects.
214 | if depth < 3: # noqa: PLR2004
215 | return package
216 | return None
217 |
218 | def build_dependencies(self) -> None:
219 | """Recursively build the dependencies for sub-modules and sub-packages.
220 |
221 | Iterate on node's modules then packages and call their
222 | build_dependencies methods.
223 | """
224 | for module in self.modules:
225 | module.build_dependencies()
226 | for package in self.packages:
227 | package.build_dependencies()
228 |
229 | def print_graph(
230 | self,
231 | format: str | None = None, # noqa: A002
232 | output: IO = sys.stdout,
233 | depth: int = 0,
234 | **kwargs: Any,
235 | ) -> None:
236 | """Print the graph for self's nodes.
237 |
238 | Args:
239 | format: Output format (csv, json or text).
240 | output: File descriptor on which to write.
241 | depth: Depth of the graph.
242 | **kwargs: Additional keyword arguments passed to `graph.print`.
243 | """
244 | graph = self.as_graph(depth=depth)
245 | graph.print(format=format, output=output, **kwargs)
246 |
247 | def print_matrix(
248 | self,
249 | format: str | None = None, # noqa: A002
250 | output: IO = sys.stdout,
251 | depth: int = 0,
252 | **kwargs: Any,
253 | ) -> None:
254 | """Print the matrix for self's nodes.
255 |
256 | Args:
257 | format: Output format (csv, json or text).
258 | output: File descriptor on which to write.
259 | depth: Depth of the matrix.
260 | **kwargs: Additional keyword arguments passed to `matrix.print`.
261 | """
262 | matrix = self.as_matrix(depth=depth)
263 | matrix.print(format=format, output=output, **kwargs)
264 |
265 | def print_treemap(self, format: str | None = None, output: IO = sys.stdout, **kwargs: Any) -> None: # noqa: A002
266 | """Print the matrix for self's nodes.
267 |
268 | Args:
269 | format: Output format (csv, json or text).
270 | output: File descriptor on which to write.
271 | **kwargs: Additional keyword arguments passed to `treemap.print`.
272 | """
273 | treemap = self.as_treemap()
274 | treemap.print(format=format, output=output, **kwargs)
275 |
276 | def _to_text(self, **kwargs: Any) -> str:
277 | indent = kwargs.pop("indent", 2)
278 | base_indent = kwargs.pop("base_indent", None)
279 | if base_indent is None:
280 | base_indent = indent
281 | indent = 0
282 | text = [" " * indent + str(self) + "\n"]
283 | new_indent = indent + base_indent
284 | for module in self.modules:
285 | text.append(module._to_text(indent=new_indent, base_indent=base_indent))
286 | for package in self.packages:
287 | text.append(package._to_text(indent=new_indent, base_indent=base_indent))
288 | return "".join(text)
289 |
290 | def _to_csv(self, **kwargs: Any) -> str:
291 | header = kwargs.pop("header", True)
292 | modules = sorted(self.submodules, key=lambda mod: mod.absolute_name())
293 | text = ["module,path,target,lineno,what,external\n" if header else ""]
294 | for module in modules:
295 | text.append(module._to_csv(header=False))
296 | return "".join(text)
297 |
298 | def _to_json(self, **kwargs: Any) -> str:
299 | return json.dumps(self.as_dict(), **kwargs)
300 |
301 | def as_dict(self) -> dict:
302 | """Return the dependencies as a dictionary.
303 |
304 | Returns:
305 | Dictionary of dependencies.
306 | """
307 | return {
308 | "name": str(self),
309 | "modules": [module.as_dict() for module in self.modules],
310 | "packages": [package.as_dict() for package in self.packages],
311 | }
312 |
313 | def as_graph(self, depth: int = 0) -> Graph:
314 | """Create a graph with self as node, cache it, return it.
315 |
316 | Args:
317 | depth: Depth of the graph.
318 |
319 | Returns:
320 | An instance of Graph.
321 | """
322 | if depth not in self._graph_cache:
323 | self._graph_cache[depth] = Graph(self, depth=depth) # type: ignore[arg-type]
324 | return self._graph_cache[depth]
325 |
326 | def as_matrix(self, depth: int = 0) -> Matrix:
327 | """Create a matrix with self as node, cache it, return it.
328 |
329 | Args:
330 | depth: Depth of the matrix.
331 |
332 | Returns:
333 | An instance of Matrix.
334 | """
335 | if depth not in self._matrix_cache:
336 | self._matrix_cache[depth] = Matrix(self, depth=depth) # type: ignore[arg-type]
337 | return self._matrix_cache[depth]
338 |
339 | def as_treemap(self) -> TreeMap:
340 | """Return the dependencies as a TreeMap.
341 |
342 | Returns:
343 | An instance of TreeMap.
344 | """
345 | if not self._treemap_cache:
346 | self._treemap_cache = TreeMap(self)
347 | return self._treemap_cache
348 |
349 |
350 | class LeafNode:
351 | """Shared code between Package and Module."""
352 |
353 | def __init__(self):
354 | """Initialization method."""
355 | self._depth_cache = None
356 |
357 | def __str__(self):
358 | return self.absolute_name()
359 |
360 | @property
361 | def root(self) -> Package:
362 | """Property to return the root of this node.
363 |
364 | Returns:
365 | Package: this node's root package.
366 | """
367 | node: Package = self # type: ignore[assignment]
368 | while node.package is not None:
369 | node = node.package
370 | return node
371 |
372 | @property
373 | def depth(self) -> int:
374 | """Property to tell the depth of the node in the tree.
375 |
376 | Returns:
377 | The node's depth in the tree.
378 | """
379 | if self._depth_cache is not None:
380 | return self._depth_cache
381 | node: Package
382 | depth, node = 1, self # type: ignore[assignment]
383 | while node.package is not None:
384 | depth += 1
385 | node = node.package
386 | self._depth_cache = depth
387 | return depth
388 |
389 | def absolute_name(self, depth: int = 0) -> str:
390 | """Return the absolute name of the node.
391 |
392 | Concatenate names from root to self within depth.
393 |
394 | Args:
395 | depth: Maximum depth to go to.
396 |
397 | Returns:
398 | Absolute name of the node (until given depth is reached).
399 | """
400 | node: Package
401 | node, node_depth = self, self.depth # type: ignore[assignment]
402 | if depth < 1:
403 | depth = node_depth
404 | while node_depth > depth and node.package is not None:
405 | node = node.package
406 | node_depth -= 1
407 | names = []
408 | while node is not None:
409 | names.append(node.name)
410 | node = node.package # type: ignore[assignment]
411 | return ".".join(reversed(names))
412 |
--------------------------------------------------------------------------------
/src/dependenpy/plugins.py:
--------------------------------------------------------------------------------
1 | """dependenpy plugins module."""
2 |
3 | from __future__ import annotations
4 |
5 | from dependenpy.dsm import DSM as DependenpyDSM # noqa: N811
6 | from dependenpy.helpers import guess_depth
7 |
8 | try:
9 | import archan
10 | except ImportError:
11 |
12 | class InternalDependencies:
13 | """Empty dependenpy provider."""
14 |
15 | else:
16 |
17 | class InternalDependencies(archan.Provider): # type: ignore[no-redef]
18 | """Dependenpy provider for Archan."""
19 |
20 | identifier = "dependenpy.InternalDependencies"
21 | name = "Internal Dependencies"
22 | description = "Provide matrix data about internal dependencies in a set of packages."
23 | argument_list = (
24 | archan.Argument("packages", list, "The list of packages to check for."),
25 | archan.Argument(
26 | "enforce_init",
27 | bool,
28 | default=True,
29 | description="Whether to assert presence of __init__.py files in directories.",
30 | ),
31 | archan.Argument("depth", int, "The depth of the matrix to generate."),
32 | )
33 |
34 | def get_data(
35 | self,
36 | packages: list[str],
37 | enforce_init: bool = True, # noqa: FBT001,FBT002
38 | depth: int | None = None,
39 | ) -> archan.DSM:
40 | """Provide matrix data for internal dependencies in a set of packages.
41 |
42 | Args:
43 | packages: the list of packages to check for.
44 | enforce_init: whether to assert presence of __init__.py files in directories.
45 | depth: the depth of the matrix to generate.
46 |
47 | Returns:
48 | Instance of archan DSM.
49 | """
50 | dsm = DependenpyDSM(*packages, enforce_init=enforce_init)
51 | if depth is None:
52 | depth = guess_depth(packages)
53 | matrix = dsm.as_matrix(depth=depth)
54 | return archan.DesignStructureMatrix(data=matrix.data, entities=matrix.keys)
55 |
--------------------------------------------------------------------------------
/src/dependenpy/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pawamoy/dependenpy/444a69f48c7b740ed151c3ea5f5bde213696e441/src/dependenpy/py.typed
--------------------------------------------------------------------------------
/src/dependenpy/structures.py:
--------------------------------------------------------------------------------
1 | """dependenpy structures module."""
2 |
3 | from __future__ import annotations
4 |
5 | import copy
6 | import json
7 | from typing import TYPE_CHECKING, Any
8 |
9 | from colorama import Style
10 |
11 | from dependenpy.helpers import PrintMixin
12 |
13 | if TYPE_CHECKING:
14 | from dependenpy.dsm import DSM, Module, Package
15 |
16 |
17 | class Matrix(PrintMixin):
18 | """Matrix class.
19 |
20 | A class to build a matrix given a list of nodes. After instantiation,
21 | it has two attributes: data, a 2-dimensions array, and keys, the names
22 | of the entities in the corresponding order.
23 | """
24 |
25 | def __init__(self, *nodes: DSM | Package | Module, depth: int = 0):
26 | """Initialization method.
27 |
28 | Args:
29 | *nodes: The nodes on which to build the matrix.
30 | depth: The depth of the matrix. This depth is always
31 | absolute, meaning that building a matrix with a sub-package
32 | "A.B.C" and a depth of 1 will return a matrix of size 1,
33 | containing A only. To see the matrix for the sub-modules and
34 | sub-packages in C, you will have to give depth=4.
35 | """
36 | modules: list[Module] = []
37 | for node in nodes:
38 | if node.ismodule:
39 | modules.append(node) # type: ignore[arg-type]
40 | elif node.ispackage or node.isdsm:
41 | modules.extend(node.submodules) # type: ignore[union-attr]
42 |
43 | if depth < 1:
44 | keys = modules
45 | else:
46 | keys = []
47 | for module in modules:
48 | if module.depth <= depth:
49 | keys.append(module)
50 | continue
51 | package = module.package
52 | while package.depth > depth and package.package and package not in nodes: # type: ignore[union-attr]
53 | package = package.package # type: ignore[union-attr]
54 | if package not in keys:
55 | keys.append(package) # type: ignore[arg-type]
56 |
57 | size = len(keys)
58 | data = [[0] * size for _ in range(size)]
59 | keys = sorted(keys, key=lambda key: key.absolute_name())
60 |
61 | if depth < 1:
62 | for index, key in enumerate(keys):
63 | key.index = index # type: ignore[attr-defined]
64 | for index, key in enumerate(keys):
65 | for dep in key.dependencies:
66 | if dep.external:
67 | continue
68 | if dep.target.ismodule and dep.target in keys: # type: ignore[union-attr]
69 | data[index][dep.target.index] += 1 # type: ignore[index,union-attr]
70 | elif dep.target.ispackage: # type: ignore[union-attr]
71 | init = dep.target.get("__init__") # type: ignore[union-attr]
72 | if init is not None and init in keys:
73 | data[index][init.index] += 1 # type: ignore[union-attr]
74 | else:
75 | for row, row_key in enumerate(keys):
76 | for col, col_key in enumerate(keys):
77 | data[row][col] = row_key.cardinal(to=col_key)
78 |
79 | self.size = size
80 | self.keys = [key.absolute_name() for key in keys]
81 | self.data = data
82 |
83 | @staticmethod
84 | def cast(keys: list[str], data: list[list[int]]) -> Matrix:
85 | """Cast a set of keys and an array to a Matrix object.
86 |
87 | Arguments:
88 | keys: The matrix keys.
89 | data: The matrix data.
90 |
91 | Returns:
92 | A new matrix.
93 | """
94 | matrix = Matrix()
95 | matrix.keys = copy.deepcopy(keys)
96 | matrix.data = copy.deepcopy(data)
97 | return matrix
98 |
99 | @property
100 | def total(self) -> int:
101 | """Return the total number of dependencies within this matrix.
102 |
103 | Returns:
104 | The total number of dependencies.
105 | """
106 | return sum(cell for line in self.data for cell in line)
107 |
108 | def _to_csv(self, **kwargs: Any) -> str: # noqa: ARG002
109 | text = ["module,", ",".join(self.keys)]
110 | for index, key in enumerate(self.keys):
111 | line = ",".join(map(str, self.data[index]))
112 | text.append(f"{key},{line}")
113 | return "\n".join(text)
114 |
115 | def _to_json(self, **kwargs: Any) -> str:
116 | return json.dumps({"keys": self.keys, "data": self.data}, **kwargs)
117 |
118 | def _to_text(self, **kwargs: Any) -> str:
119 | if not self.keys or not self.data:
120 | return ""
121 | zero = kwargs.pop("zero", "0")
122 | max_key_length = max(len(key) for key in [*self.keys, "Module"])
123 | max_dep_length = max([len(str(col)) for line in self.data for col in line] + [len(zero)])
124 | key_col_length = len(str(len(self.keys)))
125 | key_line_length = max(key_col_length, 2)
126 | column_length = max(key_col_length, max_dep_length)
127 | bold = Style.BRIGHT
128 | reset = Style.RESET_ALL
129 |
130 | # first line left headers
131 | text = [f"\n {bold}{'Module':>{max_key_length}}{reset} │ {bold}{'Id':>{key_line_length}}{reset} │"]
132 | # first line column headers
133 | for index, _ in enumerate(self.keys):
134 | text.append(f"{bold}{index:^{column_length}}{reset}│")
135 | text.append("\n")
136 | # line of dashes
137 | text.append(f" {'─' * max_key_length}─┼─{'─' * key_line_length}─┼")
138 | for _ in range(len(self.keys) - 1):
139 | text.append(f"{'─' * column_length}┼")
140 | text.append(f"{'─' * column_length}┤")
141 | text.append("\n")
142 | # lines
143 | for index, key in enumerate(self.keys):
144 | text.append(f" {key:>{max_key_length}} │ {bold}{index:>{key_line_length}}{reset} │")
145 | for value in self.data[index]:
146 | text.append(f"{value if value else zero:>{column_length}}│")
147 | text.append("\n")
148 | text.append("\n")
149 |
150 | return "".join(text)
151 |
152 |
153 | class TreeMap(PrintMixin):
154 | """TreeMap class."""
155 |
156 | def __init__(self, *nodes: Any, value: int = -1): # noqa: ARG002
157 | """Initialization method.
158 |
159 | Arguments:
160 | *nodes: the nodes from which to build the treemap.
161 | value: the value of the current area.
162 | """
163 | # if nodes:
164 | # matrix_lower_level = Matrix(*nodes, depth=2)
165 | # matrix_current_level = Matrix(*nodes, depth=1)
166 | # if value == -1:
167 | # value = sum(c for row in matrix_current_level.data for c in row)
168 | # splits = [0]
169 | # key_comp = matrix_lower_level.keys[0].split('.')[0]
170 | # i = 1
171 | # for key in matrix_lower_level.keys[1:]:
172 | # key = key.split('.')[0]
173 | # if key != key_comp:
174 | # splits.append(i)
175 | # key_comp = key
176 | # i += 1
177 | # splits.append(i)
178 | #
179 | # self.data = []
180 | # for i in range(len(splits) - 1):
181 | # self.data.append([])
182 | # rows = matrix_lower_level.data[splits[i]:splits[i+1]]
183 | # for j in range(len(splits) - 1):
184 | # self.data[i].append([row[splits[j]:splits[j+1]] for row in rows])
185 |
186 | self.value = value
187 |
188 | def _to_csv(self, **kwargs: Any) -> str: # noqa: ARG002
189 | return ""
190 |
191 | def _to_json(self, **kwargs: Any) -> str: # noqa: ARG002
192 | return ""
193 |
194 | def _to_text(self, **kwargs: Any) -> str: # noqa: ARG002
195 | return ""
196 |
197 |
198 | class Vertex:
199 | """Vertex class. Used in Graph class."""
200 |
201 | def __init__(self, name: str) -> None:
202 | """Initialization method.
203 |
204 | Args:
205 | name (str): name of the vertex.
206 | """
207 | self.name = name
208 | self.edges_in: set[Edge] = set()
209 | self.edges_out: set[Edge] = set()
210 |
211 | def __str__(self):
212 | return self.name
213 |
214 | def connect_to(self, vertex: Vertex, weight: int = 1) -> Edge:
215 | """Connect this vertex to another one.
216 |
217 | Args:
218 | vertex: Vertex to connect to.
219 | weight: Weight of the edge.
220 |
221 | Returns:
222 | The newly created edge.
223 | """
224 | for edge in self.edges_out:
225 | if vertex == edge.vertex_in:
226 | return edge
227 | return Edge(self, vertex, weight)
228 |
229 | def connect_from(self, vertex: Vertex, weight: int = 1) -> Edge:
230 | """Connect another vertex to this one.
231 |
232 | Args:
233 | vertex: Vertex to connect from.
234 | weight: Weight of the edge.
235 |
236 | Returns:
237 | The newly created edge.
238 | """
239 | for edge in self.edges_in:
240 | if vertex == edge.vertex_out:
241 | return edge
242 | return Edge(vertex, self, weight)
243 |
244 |
245 | class Edge:
246 | """Edge class. Used in Graph class."""
247 |
248 | def __init__(self, vertex_out: Vertex, vertex_in: Vertex, weight: int = 1) -> None:
249 | """Initialization method.
250 |
251 | Args:
252 | vertex_out (Vertex): source vertex (edge going out).
253 | vertex_in (Vertex): target vertex (edge going in).
254 | weight (int): weight of the edge.
255 | """
256 | self.vertex_out: Vertex | None = None
257 | self.vertex_in: Vertex | None = None
258 | self.weight = weight
259 | self.go_from(vertex_out)
260 | self.go_in(vertex_in)
261 |
262 | def __str__(self):
263 | return f"{self.vertex_out.name} --{self.weight}--> {self.vertex_in.name}"
264 |
265 | def go_from(self, vertex: Vertex) -> None:
266 | """Tell the edge to go out from this vertex.
267 |
268 | Args:
269 | vertex (Vertex): vertex to go from.
270 | """
271 | if self.vertex_out:
272 | self.vertex_out.edges_out.remove(self)
273 | self.vertex_out = vertex
274 | vertex.edges_out.add(self)
275 |
276 | def go_in(self, vertex: Vertex) -> None:
277 | """Tell the edge to go into this vertex.
278 |
279 | Args:
280 | vertex (Vertex): vertex to go into.
281 | """
282 | if self.vertex_in:
283 | self.vertex_in.edges_in.remove(self)
284 | self.vertex_in = vertex
285 | vertex.edges_in.add(self)
286 |
287 |
288 | class Graph(PrintMixin):
289 | """Graph class.
290 |
291 | A class to build a graph given a list of nodes. After instantiation,
292 | it has two attributes: vertices, the set of nodes,
293 | and edges, the set of edges.
294 | """
295 |
296 | def __init__(self, *nodes: DSM | Package | Module, depth: int = 0) -> None:
297 | """Initialization method.
298 |
299 | An intermediary matrix is built to ease the creation of the graph.
300 |
301 | Args:
302 | *nodes (list of DSM/Package/Module):
303 | the nodes on which to build the graph.
304 | depth (int): the depth of the intermediary matrix. See
305 | the documentation for Matrix class.
306 | """
307 | self.edges = set()
308 | vertices = []
309 | matrix = Matrix(*nodes, depth=depth)
310 | for key in matrix.keys:
311 | vertices.append(Vertex(key))
312 | for line_index, line in enumerate(matrix.data):
313 | for col_index, cell in enumerate(line):
314 | if cell > 0:
315 | self.edges.add(Edge(vertices[line_index], vertices[col_index], weight=cell))
316 | self.vertices = set(vertices)
317 |
318 | def _to_csv(self, **kwargs: Any) -> str:
319 | header = kwargs.pop("header", True)
320 | text = ["vertex_out,edge_weight,vertex_in\n" if header else ""]
321 | for edge in self.edges:
322 | text.append(f"{edge.vertex_out.name},{edge.weight},{edge.vertex_in.name}\n") # type: ignore[union-attr]
323 | for vertex in self.vertices:
324 | if not (vertex.edges_out or vertex.edges_in):
325 | text.append("{vertex.name},,\n")
326 | return "".join(text)
327 |
328 | def _to_json(self, **kwargs: Any) -> str:
329 | return json.dumps(
330 | {
331 | "vertices": [vertex.name for vertex in self.vertices],
332 | "edges": [
333 | {"out": edge.vertex_out.name, "weight": edge.weight, "in": edge.vertex_in.name} # type: ignore[union-attr]
334 | for edge in self.edges
335 | ],
336 | },
337 | **kwargs,
338 | )
339 |
340 | def _to_text(self, **kwargs: Any) -> str: # noqa: ARG002
341 | return ""
342 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Tests suite for `dependenpy`."""
2 |
3 | import sys
4 | from pathlib import Path
5 |
6 | TESTS_DIR = Path(__file__).parent
7 | TMP_DIR = TESTS_DIR / "tmp"
8 | FIXTURES_DIR = TESTS_DIR / "fixtures"
9 |
10 |
11 | sys.path.insert(0, str(FIXTURES_DIR))
12 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """Configuration for the pytest test suite."""
2 |
--------------------------------------------------------------------------------
/tests/fixtures/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pawamoy/dependenpy/444a69f48c7b740ed151c3ea5f5bde213696e441/tests/fixtures/.gitkeep
--------------------------------------------------------------------------------
/tests/fixtures/external/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pawamoy/dependenpy/444a69f48c7b740ed151c3ea5f5bde213696e441/tests/fixtures/external/__init__.py
--------------------------------------------------------------------------------
/tests/fixtures/external/module_a.py:
--------------------------------------------------------------------------------
1 | class ClassA(object):
2 | pass
3 |
--------------------------------------------------------------------------------
/tests/fixtures/internal/__init__.py:
--------------------------------------------------------------------------------
1 | from . import subpackage_1
2 |
--------------------------------------------------------------------------------
/tests/fixtures/internal/module_a.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import external
4 | from external import module_a
5 | from external.module_a import ClassA
6 |
7 | from . import subpackage_1 as indirect_subpackage_1
8 | from . import subpackage_a
9 | from .subpackage_a import subpackage_1
10 |
11 |
12 | class ClassA(object):
13 | pass
14 |
--------------------------------------------------------------------------------
/tests/fixtures/internal/subpackage_a/__init__.py:
--------------------------------------------------------------------------------
1 | import external as ex
2 |
--------------------------------------------------------------------------------
/tests/fixtures/internal/subpackage_a/module_1.py:
--------------------------------------------------------------------------------
1 | import internal.subpackage_a.subpackage_1.module_i
2 |
3 |
4 | class Class1(object):
5 | def function(self):
6 | import sys
7 |
--------------------------------------------------------------------------------
/tests/fixtures/internal/subpackage_a/subpackage_1/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pawamoy/dependenpy/444a69f48c7b740ed151c3ea5f5bde213696e441/tests/fixtures/internal/subpackage_a/subpackage_1/__init__.py
--------------------------------------------------------------------------------
/tests/fixtures/internal/subpackage_a/subpackage_1/module_i.py:
--------------------------------------------------------------------------------
1 | class ClassI(object):
2 | from ..module_1 import Class1
3 |
4 |
5 | def function():
6 | from ...module_a import ClassA
7 |
8 | def inner_function():
9 | from ...subpackage_a.module_1 import Class1
10 |
11 | class InnerClass(object):
12 | from external import module_a
13 |
14 | return inner_function
15 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | """Tests for the `cli` module."""
2 |
3 | from __future__ import annotations
4 |
5 | import pytest
6 |
7 | from dependenpy import cli
8 |
9 |
10 | def test_main() -> None:
11 | """Basic CLI test."""
12 | with pytest.raises(SystemExit) as exit: # noqa: PT012
13 | cli.main([])
14 | assert exit.code == 2 # type: ignore[attr-defined]
15 |
16 |
17 | def test_show_help(capsys: pytest.CaptureFixture) -> None:
18 | """Show help.
19 |
20 | Parameters:
21 | capsys: Pytest fixture to capture output.
22 | """
23 | with pytest.raises(SystemExit):
24 | cli.main(["-h"])
25 | captured = capsys.readouterr()
26 | assert "dependenpy" in captured.out
27 |
--------------------------------------------------------------------------------
/tests/test_dependenpy.py:
--------------------------------------------------------------------------------
1 | """Tests for main features."""
2 |
3 | from __future__ import annotations
4 |
5 | import pytest
6 |
7 | from dependenpy.cli import main
8 | from dependenpy.dsm import DSM
9 |
10 |
11 | @pytest.mark.parametrize(
12 | "args",
13 | [
14 | ["-l", "dependenpy"],
15 | ["-m", "dependenpy"],
16 | ["-t", "dependenpy"],
17 | ["dependenpy", "-d100"],
18 | ["dependenpy,internal,dependenpy"],
19 | ],
20 | )
21 | def test_main_ok(args: list[str]) -> None:
22 | """Main test method.
23 |
24 | Arguments:
25 | args: Command line arguments.
26 | """
27 | assert main(args) == 0
28 |
29 |
30 | def test_main_not_ok() -> None:
31 | """Main test method."""
32 | assert main(["do not exist"]) == 1
33 |
34 |
35 | def test_tree() -> None:
36 | """Test the built tree."""
37 | dsm = DSM("internal")
38 | items = [
39 | "internal",
40 | "internal.subpackage_a",
41 | "internal.subpackage_a.subpackage_1",
42 | "internal.subpackage_a.subpackage_1.__init__",
43 | "internal.subpackage_a.subpackage_1.module_i",
44 | "internal.subpackage_a.__init__",
45 | "internal.subpackage_a.module_1",
46 | "internal.__init__",
47 | "internal.module_a",
48 | ]
49 | for item in items:
50 | assert dsm.get(item)
51 |
52 |
53 | def test_inner_imports() -> None:
54 | """Test inner imports."""
55 | dsm = DSM("internal")
56 | module_i = dsm["internal.subpackage_a.subpackage_1.module_i"]
57 | assert len(module_i.dependencies) == 4 # type: ignore[union-attr]
58 | assert module_i.cardinal(to=dsm["internal"]) == 3
59 |
60 |
61 | def test_delayed_build() -> None:
62 | """Test delayed build."""
63 | dsm = DSM("internal", build_tree=False)
64 | dsm.build_tree()
65 | dsm.build_dependencies()
66 | assert len(dsm.submodules) == 6
67 |
--------------------------------------------------------------------------------