├── .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 | [![ci](https://github.com/pawamoy/dependenpy/workflows/ci/badge.svg)](https://github.com/pawamoy/dependenpy/actions?query=workflow%3Aci) 4 | [![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://pawamoy.github.io/dependenpy/) 5 | [![pypi version](https://img.shields.io/pypi/v/dependenpy.svg)](https://pypi.org/project/dependenpy/) 6 | [![gitpod](https://img.shields.io/badge/gitpod-workspace-blue.svg?style=flat)](https://gitpod.io/#https://github.com/pawamoy/dependenpy) 7 | [![gitter](https://badges.gitter.im/join%20chat.svg)](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 | ![demo](demo.svg) 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 | --------------------------------------------------------------------------------