├── .coveragerc
├── .flake8
├── .github
├── assets
│ └── example-terminal.png
└── workflows
│ ├── main.yml
│ └── publish.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE.md
├── Makefile
├── README.md
├── android_resources_checker
├── __init__.py
├── analyzer.py
├── app.py
├── entrypoint.py
├── files.py
├── models.py
├── reporting.py
├── resources.py
└── validator.py
├── poetry.lock
├── pyproject.toml
└── tests
├── __init__.py
├── fixtures
├── dummy-anim.xml
├── dummy-class.kt
├── dummy-color.xml
├── dummy-dimens.xml
├── dummy-drawable.xml
├── dummy-layout.xml
├── dummy-styles.kt
├── dummy-styles.xml
└── dummy-values-color.xml
├── test_reporting.py
├── test_resources_analyzer.py
├── test_resources_fetcher.py
└── test_validator.py
/.coveragerc:
--------------------------------------------------------------------------------
1 |
2 | [run]
3 | omit =
4 | android_resources_checker/app.py
5 | android_resources_checker/__init__.py
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length: 100
3 | exclude: .yml, .md
--------------------------------------------------------------------------------
/.github/assets/example-terminal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fbcbl/android-resources-checker/b4c35ab14789599775423bf4519b7348e0d1ce9b/.github/assets/example-terminal.png
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Main
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | main:
11 | strategy:
12 | fail-fast: true
13 | matrix:
14 | os: ['ubuntu-18.04']
15 | python: ['3.8.8', '3.9.2']
16 |
17 | runs-on: ${{ matrix.os }}
18 |
19 | steps:
20 | - name: Project checkout
21 | uses: actions/checkout@v2.3.4
22 |
23 | - name: Setup Python
24 | uses: actions/setup-python@v2.2.1
25 | with:
26 | python-version: ${{ matrix.python }}
27 |
28 | - name: Install Poetry
29 | uses: snok/install-poetry@v1.1.2
30 | with:
31 | virtualenvs-create: false
32 | virtualenvs-in-project: false
33 |
34 | - name: Install dependencies
35 | run: make setup
36 |
37 | - name: Check codestyle
38 | run: make inspect
39 |
40 | - name: Run tests
41 | run: make test
42 |
43 | - name: Build package
44 | run: make build
45 |
46 | - name: Upload test reports to Codecov
47 | uses: codecov/codecov-action@v1.3.1
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types:
6 | - created
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-18.04
11 |
12 | steps:
13 | - name: Project checkout
14 | uses: actions/checkout@v2.3.4
15 |
16 | - name: Setup Python
17 | uses: actions/setup-python@v2.2.1
18 | with:
19 | python-version: '3.8.8'
20 |
21 | - name: Install Poetry
22 | uses: snok/install-poetry@v1.1.2
23 | with:
24 | virtualenvs-create: false
25 | virtualenvs-in-project: false
26 |
27 | - name: Install dependencies
28 | run: make setup
29 |
30 | - name: Publish to Pypi
31 | run: make deploy token=${{ secrets.PYPI_UPLOAD_TOKEN }}
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
134 | # pytype static type analyzer
135 | .pytype/
136 |
137 | # Cython debug symbols
138 | cython_debug/
139 |
140 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
141 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
142 |
143 | # User-specific stuff
144 | .idea/**/workspace.xml
145 | .idea/**/tasks.xml
146 | .idea/**/usage.statistics.xml
147 | .idea/**/dictionaries
148 | .idea/**/shelf
149 |
150 | # Generated files
151 | .idea/**/contentModel.xml
152 |
153 | # Sensitive or high-churn files
154 | .idea/**/dataSources/
155 | .idea/**/dataSources.ids
156 | .idea/**/dataSources.local.xml
157 | .idea/**/sqlDataSources.xml
158 | .idea/**/dynamic.xml
159 | .idea/**/uiDesigner.xml
160 | .idea/**/dbnavigator.xml
161 |
162 | # Gradle
163 | .idea/**/gradle.xml
164 | .idea/**/libraries
165 |
166 | # Gradle and Maven with auto-import
167 | # When using Gradle or Maven with auto-import, you should exclude module files,
168 | # since they will be recreated, and may cause churn. Uncomment if using
169 | # auto-import.
170 | # .idea/artifacts
171 | # .idea/compiler.xml
172 | # .idea/jarRepositories.xml
173 | # .idea/modules.xml
174 | # .idea/*.iml
175 | # .idea/modules
176 | # *.iml
177 | # *.ipr
178 |
179 | # CMake
180 | cmake-build-*/
181 |
182 | # Mongo Explorer plugin
183 | .idea/**/mongoSettings.xml
184 |
185 | # File-based project format
186 | *.iws
187 |
188 | # IntelliJ
189 | out/
190 |
191 | # mpeltonen/sbt-idea plugin
192 | .idea_modules/
193 |
194 | # JIRA plugin
195 | atlassian-ide-plugin.xml
196 |
197 | # Cursive Clojure plugin
198 | .idea/replstate.xml
199 |
200 | # Crashlytics plugin (for Android Studio and IntelliJ)
201 | com_crashlytics_export_strings.xml
202 | crashlytics.properties
203 | crashlytics-build.properties
204 | fabric.properties
205 |
206 | # Editor-based Rest Client
207 | .idea/httpRequests
208 |
209 | # Android studio 3.1+ serialized cache file
210 | .idea/caches/build_file_checksums.ser
211 |
212 | .idea/
213 | .python-version
214 |
215 | # reports
216 | **.csv
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## [UNRELEASED]
4 |
5 | # Version 0.0.10
6 |
7 | - Specify automatic deletion of unused resources via the `--delete` flag.
8 | - Fixes bugs in deletion that incorrectly deleted entries in file and whole files.
9 |
10 | # Version 0.0.9
11 |
12 | - Fix bug that was not considering resource names with '0' in the name.
13 | - Fix bug that was not considering all files of the project.
14 |
15 | ## Version 0.0.8
16 |
17 | - Add ability to use it as a validation tool using the `--check` option.
18 | - Specify the type of report using the `--report=(CSV|STDOUT)` (the default is to run all).
19 | - Fixes bug that didn't check for resource usages in the `AndroidManifest.xml`
20 |
21 | ## Version 0.0.7
22 |
23 | - Add inspection to `style` resources.
24 | - Update `LICENSE.md` and `README.md` with due copyright to Dotanuki Labs given that the structure/base of this project
25 | was highly inspired from [Bitrise Reports](https://github.com/dotanuki-labs/bitrise-reports).
26 |
27 | ## Version 0.0.6
28 |
29 | **2021-04-02**
30 |
31 | - Add CSV reports via the `--reports-dir` option.
32 | - Fixes bug that ignored that didn't process resources that are not xml (such as the ones usually placed on `raw`)
33 |
34 | ## Version 0.0.4
35 |
36 | **2021-04-01**
37 |
38 | - Add inspection to resources declared as entries (`string`, `color`, `dimen`)
39 | - Renamed `--app-path` to `--app`
40 | - Renamed `--client-path` to `--client`
41 | - Add ability to provide multiple clients.
42 | * This is done via the `--client` flag; you should use one for each of the clients.
43 |
44 | ## Version 0.0.3
45 |
46 | **2021-03-29**
47 |
48 | ### Features:
49 |
50 | - Identify the unused resources in your android project.
51 | - Identify the unused resources in your android library (when you have a multi-repo setup)
52 | - Listing of the unused resources (name, type and size)
53 | - Deletion of the unused resources
54 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Fábio Carballo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | # Install dependencies
3 | setup:
4 | poetry update
5 | poetry install
6 | poetry config virtualenvs.in-project true
7 | pip install flake8
8 | pip install black
9 |
10 | inspect:
11 | flake8 android_resources_checker tests
12 | black --check android_resources_checker tests
13 |
14 | build:
15 | poetry build
16 |
17 | test:
18 | poetry run pytest -vv --cov-report=xml --cov=android_resources_checker tests/
19 |
20 | standard-inspection:
21 | poetry run android-resources-checker \
22 | --app-path=$(app-path)
23 |
24 | ## Deploy the current build to Pypi
25 | deploy:
26 | poetry config pypi-token.pypi $(token)
27 | poetry build
28 | poetry publish
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Android Resources Checker
2 |
3 | [](https://flake8.pycqa.org/en/latest/)
4 | [](https://github.com/psf/black)
5 | [](https://codecov.io/gh/fabiocarballo/android-resources-checker)
6 | [](https://choosealicense.com/licenses/mit)
7 |
8 | ## What
9 |
10 | This program will inspect the resources of your app and help you understand which ones are not being used and could
11 | potentially be removed.
12 |
13 | Main features:
14 |
15 | - Identify the unused resources in your android project.
16 | - Identify the unused resources in your android library (when you have a multi-repo setup)
17 | - Listing of the unused resources (name, type and size)
18 | - Deletion of the unused resources
19 |
20 | ## Installing
21 |
22 | This program requires Python, supporting from 3.8.x and 3.9.x
23 |
24 | In order to install run:
25 |
26 | ```shell
27 | pip install -U android-resources-checker
28 | ```
29 |
30 | ## Using
31 |
32 | ## Inspecting your app resources.
33 |
34 | Imagining your app in the project `subject-app`, you can trigger the resources inspection by running:
35 |
36 | ```shell
37 | android-resources-checker --app /path/to/subject-app
38 | ```
39 |
40 | ## Inspecting your library app resources.
41 |
42 | In the case you have two projects in separate repos, where a `client-app` depends on a `lib-app`, you can check the
43 | unused resources of the library app by running:
44 |
45 | ```shell
46 | android-resources-checker \
47 | --app /path/to/lib-app \
48 | --client /path/to/client-app-1 \
49 | --client /path/to/client-app-2
50 | ```
51 |
52 | An example of a run could look like this:
53 |
54 | 
55 |
56 | ## Reports
57 |
58 | The default behavior is to generate reports on both the stdout and CSV.
59 |
60 | You can specify a single type of report using the `--report=(CSV|STDOUT)` option.
61 |
62 | If using CSV reports, you can specify the directory where to write the reports in the form of CSV files. For that use
63 | the `--reports-dir` option.
64 |
65 | For example:
66 |
67 | ```shell
68 | android-resources-checker \
69 | --app /path/to/app \
70 | --reports-dir /path/to/reports
71 | ```
72 |
73 | ## Validation
74 |
75 | There is also the option to run this as a validation tool. In this case, it will fail with an error if any unused
76 | resources are found.
77 |
78 | To specify the validation use the `--check` flag (the default behavior is to perform no validation).
79 |
80 | ## Automatic Deletion
81 |
82 | You can use this tool to also delete the unused resources by using the `--delete` option.
83 |
84 | ## License
85 |
86 | ```
87 |
88 | Copyright (c) 2021 Dotanuki Labs, Fabio Carballo
89 |
90 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
91 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
92 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
93 | persons to whom the Software is furnished to do so, subject to the following conditions:
94 |
95 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
96 | Software.
97 |
98 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
99 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
100 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
101 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
102 |
103 | ```
104 |
105 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/android_resources_checker/__init__.py:
--------------------------------------------------------------------------------
1 | from . import entrypoint
2 |
3 |
4 | def main(argv=None):
5 | entrypoint.launch()
6 |
--------------------------------------------------------------------------------
/android_resources_checker/analyzer.py:
--------------------------------------------------------------------------------
1 | # analyzer.py
2 | from typing import Dict, Set
3 |
4 | from .models import AnalysisBreakdown, ResourceType, PackagedResource
5 |
6 |
7 | class ResourcesAnalyzer:
8 | def _create_breakdown(
9 | self, resources_list
10 | ) -> Dict[ResourceType, Set[PackagedResource]]:
11 | breakdown = {}
12 | for resource_type in ResourceType:
13 | resources_of_type = set(
14 | [
15 | pr
16 | for pr in resources_list
17 | if pr.resource.resource_type == resource_type
18 | ]
19 | )
20 | breakdown[resource_type] = resources_of_type
21 |
22 | return dict(sorted(breakdown.items(), key=lambda item: item[0].name))
23 |
24 | def analyze(self, name, usage_references, packaged_resources) -> AnalysisBreakdown:
25 | used_packaged_resources = []
26 | unused_packaged_resources = []
27 |
28 | for pr in packaged_resources:
29 | if pr.resource in usage_references:
30 | used_packaged_resources.append(pr)
31 | else:
32 | unused_packaged_resources.append(pr)
33 |
34 | return AnalysisBreakdown(
35 | project_name=name,
36 | used_resources=self._create_breakdown(used_packaged_resources),
37 | unused_resources=self._create_breakdown(unused_packaged_resources),
38 | unused_size_bytes=sum([pr.size for pr in unused_packaged_resources]),
39 | )
40 |
--------------------------------------------------------------------------------
/android_resources_checker/app.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from rich.console import Console
4 |
5 | from android_resources_checker.validator import UnusedResourcesException
6 |
7 |
8 | class Application(object):
9 | def __init__(
10 | self, resources_fetcher, resources_modifier, analyzer, reporter, validator
11 | ):
12 | self.resources_fetcher = resources_fetcher
13 | self.resources_modifier = resources_modifier
14 | self.analyzer = analyzer
15 | self.reporter = reporter
16 | self.validator = validator
17 |
18 | def execute(self, app_path, clients, check, delete):
19 | self.reporter.apps(app_path, clients)
20 |
21 | app_name = app_path.split("/")[-1]
22 |
23 | # fetch resources data
24 | console = Console()
25 |
26 | self.reporter.resources_processing_started()
27 | with console.status("[bold green]Processing project resources..."):
28 | packaged_resources = self.resources_fetcher.fetch_packaged_resources(
29 | app_path
30 | )
31 | console.log(f"{app_name} - packaged resources processed!")
32 |
33 | usage_references = self.resources_fetcher.fetch_used_resources(app_path)
34 | console.log(f"{app_name} - used resources processed!")
35 | for client in clients:
36 | client_app_name = client.split("/")[-1]
37 | usage_references = usage_references.union(
38 | self.resources_fetcher.fetch_used_resources(client)
39 | )
40 | console.log(f"{client_app_name} - used resources processed!")
41 |
42 | # analyze data
43 | analysis = self.analyzer.analyze(
44 | app_path.split("/")[-1], usage_references, packaged_resources
45 | )
46 |
47 | # report
48 | self.reporter.reporting_started(analysis)
49 | self.reporter.report(analysis)
50 |
51 | # check
52 | if check:
53 | try:
54 | self.validator.validate(analysis)
55 | except UnusedResourcesException as e:
56 | paths = "\n".join([r.filepath for r in e.unused_resources])
57 | self.reporter.error(f"\nUnused Resources have been found!\n{paths}")
58 | sys.exit(1)
59 |
60 | if delete:
61 | resources_to_delete = set.union(*analysis.unused_resources.values())
62 | self.resources_modifier.delete_resources(resources_to_delete)
63 | self.reporter.deletion_completed(len(resources_to_delete))
64 |
--------------------------------------------------------------------------------
/android_resources_checker/entrypoint.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import sys
3 |
4 | import click
5 | from rich.console import Console
6 |
7 | from .analyzer import ResourcesAnalyzer
8 | from .app import Application
9 | from .files import FilesHandler
10 | from .reporting import Reporter, CsvAnalysisReporter, StdoutReporter
11 | from .resources import ResourcesFetcher, ResourcesModifier
12 | from .validator import Validator
13 |
14 | LIB_PROJECT_HELP = (
15 | "The path to the android project whose resources you want to inspect."
16 | )
17 | CLIENT_PROJECT_HELP = (
18 | "The path to the client android project that consumes the reference"
19 | " project resources"
20 | )
21 |
22 |
23 | @click.command()
24 | @click.option(
25 | "--app",
26 | type=click.Path(resolve_path=True, exists=True, file_okay=False),
27 | required=True,
28 | help=LIB_PROJECT_HELP,
29 | )
30 | @click.option(
31 | "--client",
32 | type=click.Path(resolve_path=True, exists=True, file_okay=True),
33 | multiple=True,
34 | required=False,
35 | help=CLIENT_PROJECT_HELP,
36 | )
37 | @click.option(
38 | "--reports-dir",
39 | type=click.Path(resolve_path=True, exists=True, file_okay=False),
40 | required=False,
41 | default=".",
42 | help="The directory where the csv reports will be written.",
43 | )
44 | @click.option(
45 | "--check",
46 | is_flag=True,
47 | default=False,
48 | help="Using this flag will fail the execution if any unused resources are found",
49 | )
50 | @click.option(
51 | "--report",
52 | type=click.Choice(["CSV", "STDOUT"], case_sensitive=False),
53 | required=False,
54 | )
55 | @click.option(
56 | "--delete",
57 | is_flag=True,
58 | default=False,
59 | help="Using this flag will automatically delete the unused resources",
60 | )
61 | def launch(app, client, report, reports_dir, check, delete):
62 | try:
63 | console = Console()
64 | error_console = Console(stderr=True, style="bold red")
65 |
66 | stdout_reporter = StdoutReporter(console)
67 | csv_reporter = CsvAnalysisReporter(console, reports_dir)
68 | choice_reporters = {
69 | None: [stdout_reporter, csv_reporter],
70 | "CSV": [csv_reporter],
71 | "STDOUT": [stdout_reporter],
72 | }
73 |
74 | application = Application(
75 | resources_fetcher=ResourcesFetcher(FilesHandler()),
76 | resources_modifier=ResourcesModifier(),
77 | analyzer=ResourcesAnalyzer(),
78 | reporter=Reporter(console, error_console, choice_reporters[report]),
79 | validator=Validator(),
80 | )
81 | application.execute(app, client, check, delete)
82 | sys.exit(0)
83 | except Exception:
84 | logging.exception("Could not complete analysis.")
85 | sys.exit(1)
86 |
--------------------------------------------------------------------------------
/android_resources_checker/files.py:
--------------------------------------------------------------------------------
1 | import glob
2 | import os
3 | import xml.etree.ElementTree
4 |
5 |
6 | class FilesHandler:
7 | def resource_files(self, root):
8 | return [
9 | os.path.join(d, x)
10 | for d, dirs, files in os.walk(root)
11 | for x in files
12 | if "/res/" in d
13 | ]
14 |
15 | def files_by_type(self, root, extension):
16 | return [
17 | os.path.join(d, x)
18 | for d, dirs, files in os.walk(root)
19 | for x in files
20 | if x.endswith(extension)
21 | ]
22 |
23 | def java_kt_files(self, root):
24 | java_files = glob.glob(root + "/**/*.java", recursive=True)
25 | kotlin_files = glob.glob(root + "/**/*.kt", recursive=True)
26 |
27 | return java_files + kotlin_files
28 |
29 | def file_size(self, filepath):
30 | return os.stat(filepath).st_size
31 |
32 | def file_content(self, filepath):
33 | return open(filepath).readlines()
34 |
35 | def xml_tree(self, xml_filepath):
36 | return xml.etree.ElementTree.parse(xml_filepath)
37 |
--------------------------------------------------------------------------------
/android_resources_checker/models.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from enum import Enum
3 |
4 | from typing import Dict, Set
5 |
6 |
7 | class ResourceType(Enum):
8 | drawable = "drawable"
9 | color = "color"
10 | anim = "anim"
11 | raw = "raw"
12 | dimen = "dimen"
13 | string = "string"
14 | style = "style"
15 |
16 |
17 | @dataclass(frozen=True)
18 | class PackagingType(Enum):
19 | file = "file"
20 | entry = "entry"
21 |
22 |
23 | @dataclass(frozen=True)
24 | class ResourceReference:
25 | name: str
26 | resource_type: ResourceType
27 |
28 |
29 | @dataclass(frozen=True)
30 | class PackagedResource:
31 | resource: ResourceReference
32 | packaging_type: PackagingType
33 | filepath: str
34 | size: int
35 |
36 |
37 | @dataclass(frozen=True)
38 | class AnalysisBreakdown:
39 | project_name: str
40 | used_resources: Dict[ResourceType, Set[PackagedResource]]
41 | unused_resources: Dict[ResourceType, Set[PackagedResource]]
42 | unused_size_bytes: int
43 |
44 |
45 | class ReportType(Enum):
46 | csv = "csv"
47 | stdout = "stdout"
48 |
--------------------------------------------------------------------------------
/android_resources_checker/reporting.py:
--------------------------------------------------------------------------------
1 | import csv
2 |
3 | from rich.table import Table
4 |
5 | from .models import ResourceType, AnalysisBreakdown, PackagingType
6 |
7 | HEADER_RESOURCE_TYPE = "Resource Type"
8 | HEADER_NUM_USED_RESOURCES = "Num used resources"
9 | HEADER_NUM_UNUSED_RESOURCES = "Num unused resources"
10 | HEADER_RESOURCE_NAME = "Resource Entry"
11 | HEADER_RESOURCE_LOCATION = "Resource Location"
12 | HEADER_RESOURCE_SIZE = "Resource Size"
13 |
14 |
15 | def _format_bytes(size):
16 | # 2**10 = 1024
17 | power = 2 ** 10
18 | n = 0
19 | power_labels = {0: "", 1: "kilo", 2: "mega", 3: "giga", 4: "tera"}
20 | while size > power:
21 | size /= power
22 | n += 1
23 | return f"{size:.2f} {str(power_labels[n])}bytes"
24 |
25 |
26 | def _format_to_kb(size):
27 | return f"{size / 2 ** 10:.2f} kb"
28 |
29 |
30 | class Reporter:
31 | def __init__(self, console, error_console, reporters):
32 | self.console = console
33 | self.error_console = error_console
34 | self.reporters = reporters
35 |
36 | def apps(self, lib_app_path, clients):
37 | self.__print("Reference app", lib_app_path, "cyan")
38 | for client in clients:
39 | self.__print("Client app", client, "cyan")
40 |
41 | def deletion_completed(self, num_resources):
42 | self.console.print(f"{num_resources} resources deleted! :rocket:")
43 |
44 | def resources_processing_started(self):
45 | self.console.print("\n[bold]Processing:[/bold]")
46 |
47 | def reporting_started(self, breakdown):
48 | self.console.print("\n[bold]Reporting:[/bold]")
49 | self.console.print(
50 | f"\nProject: [bold green]{breakdown.project_name}[/bold green]"
51 | )
52 | self.console.print(
53 | f"\nPotential size savings → {_format_bytes(breakdown.unused_size_bytes)}"
54 | )
55 |
56 | def report(self, breakdown):
57 | for reporter in self.reporters:
58 | reporter.report(breakdown)
59 | reporter.report_unused_resources_list(breakdown)
60 |
61 | def error(self, error):
62 | self.error_console.print(error)
63 |
64 | def __print(self, prefix, content, color):
65 | printer = self.console
66 | printer.print(f"{prefix} → [bold {color}]{content}[/bold {color}]")
67 |
68 |
69 | class AnalysisReporter:
70 | def __init__(self, console):
71 | self.console = console
72 |
73 | def report(self, breakdown: AnalysisBreakdown):
74 | pass
75 |
76 | def report_unused_resources_list(self, breakdown: AnalysisBreakdown):
77 | pass
78 |
79 | def _resources_usage_report_content(self, breakdown):
80 | content = [
81 | [
82 | HEADER_RESOURCE_TYPE,
83 | HEADER_NUM_USED_RESOURCES,
84 | HEADER_NUM_UNUSED_RESOURCES,
85 | ]
86 | ]
87 | for resource_type in sorted(ResourceType, key=lambda r: r.name):
88 | row = [
89 | resource_type.name,
90 | str(len(breakdown.used_resources.get(resource_type, []))),
91 | str(len(breakdown.unused_resources.get(resource_type, []))),
92 | ]
93 | content.append(row)
94 |
95 | return content
96 |
97 | def _resources_unused_resources_report_content(self, breakdown):
98 | content = [
99 | [HEADER_RESOURCE_NAME, HEADER_RESOURCE_LOCATION, HEADER_RESOURCE_SIZE]
100 | ]
101 |
102 | for grouped_resources in breakdown.unused_resources.values():
103 | sorted_resources = sorted(grouped_resources, key=lambda r: r.filepath)
104 | for r in sorted_resources:
105 | resource_entry = r.filepath
106 | if r.packaging_type is PackagingType.entry:
107 | resource_entry += f" ({r.resource.name})"
108 |
109 | content.append(
110 | [
111 | f"R.{r.resource.resource_type.name}.{r.resource.name}",
112 | r.filepath,
113 | _format_to_kb(r.size),
114 | ]
115 | )
116 |
117 | return content
118 |
119 |
120 | class CsvAnalysisReporter(AnalysisReporter):
121 | def __init__(self, console, reports_dir):
122 | super().__init__(console)
123 | self.reports_dir = reports_dir
124 |
125 | def report(self, breakdown: AnalysisBreakdown):
126 | file = "resources_usage.csv"
127 | report_filepath = f"{self.reports_dir}/{file}"
128 |
129 | content = self._resources_usage_report_content(breakdown)
130 |
131 | with open(report_filepath, "w", newline="") as csv_file:
132 | csv_writer = csv.writer(csv_file, delimiter=";")
133 | for row in content:
134 | csv_writer.writerow(row)
135 |
136 | self.console.log(f"Resources usage breakdown written in {report_filepath}")
137 |
138 | def report_unused_resources_list(self, breakdown: AnalysisBreakdown):
139 | file = "unused_resources.csv"
140 | report_filepath = f"{self.reports_dir}/{file}"
141 |
142 | content = self._resources_unused_resources_report_content(breakdown)
143 |
144 | csv_writer = csv.writer(open(report_filepath, "w", newline=""), delimiter=";")
145 | for row in content:
146 | csv_writer.writerow(row)
147 |
148 | self.console.log(f"Unused resources list written in {report_filepath}")
149 |
150 |
151 | class StdoutReporter(AnalysisReporter):
152 | def report(self, breakdown: AnalysisBreakdown):
153 | printer = self.console
154 | printer.print("\n[bold green]Resources Usage[/bold green]")
155 |
156 | table = Table(show_header=True, header_style="bold magenta")
157 | table.pad_edge = False
158 |
159 | content = self._resources_usage_report_content(breakdown)
160 |
161 | for header in content[0]:
162 | table.add_column(header)
163 |
164 | for row in content[1:]:
165 | table.add_row(*row)
166 |
167 | printer.print(table)
168 |
169 | def report_unused_resources_list(self, breakdown):
170 | printer = self.console
171 |
172 | printer.print("\n[bold green]Unused Resources List[/bold green]")
173 | table = Table(show_header=True, header_style="bold magenta")
174 | table.pad_edge = False
175 |
176 | content = self._resources_unused_resources_report_content(breakdown)
177 |
178 | for header in content[0]:
179 | table.add_column(header)
180 |
181 | for row in content[1:]:
182 | table.add_row(*row)
183 |
184 | printer.print(table)
185 |
--------------------------------------------------------------------------------
/android_resources_checker/resources.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 | from typing import Set
5 | import xml.etree.ElementTree as ET
6 |
7 | from .models import ResourceReference, ResourceType, PackagedResource, PackagingType
8 |
9 |
10 | class ResourcesFetcher:
11 | def __init__(self, files_handler):
12 | self.files_handler = files_handler
13 |
14 | def fetch_packaged_resources(self, project_path) -> Set[PackagedResource]:
15 | resource_files = self.files_handler.resource_files(project_path)
16 | xml_files = [f for f in resource_files if f.endswith(".xml")]
17 |
18 | file_resources = self._extract_file_resources(resource_files)
19 | entry_resources = self._extract_entry_resources(xml_files)
20 |
21 | return file_resources.union(file_resources.union(entry_resources))
22 |
23 | def _extract_entry_resources(self, xml_files):
24 | resources = set()
25 | for filepath in xml_files:
26 | tree = self.files_handler.xml_tree(filepath)
27 | entry_resource_types = ["dimen", "string", "color", "style"]
28 | for resource_type in entry_resource_types:
29 | for entry in tree.findall(resource_type):
30 | resources.add(
31 | PackagedResource(
32 | resource=ResourceReference(
33 | entry.get("name"), ResourceType[resource_type]
34 | ),
35 | filepath=filepath,
36 | size=0,
37 | packaging_type=PackagingType.entry,
38 | )
39 | )
40 |
41 | return resources
42 |
43 | def _extract_file_resources(self, resource_files):
44 | resources = set()
45 | for filepath in resource_files:
46 | match = re.match(".*/(" + RESOURCES_OPTIONS + ").*/", filepath)
47 | if match is not None:
48 | filename = filepath.split("/")[-1]
49 | resource_name = filename.split(".")[0]
50 | resource_type = match.groups()[0]
51 | resource_size = self.files_handler.file_size(filepath)
52 | resource_ref = ResourceReference(
53 | resource_name, ResourceType[resource_type]
54 | )
55 |
56 | resources.add(
57 | PackagedResource(
58 | resource=resource_ref,
59 | filepath=filepath,
60 | size=resource_size,
61 | packaging_type=PackagingType.file,
62 | )
63 | )
64 |
65 | return resources
66 |
67 | def fetch_used_resources(self, project_path) -> Set[ResourceReference]:
68 | resources = set()
69 |
70 | xml_regex = "@(" + RESOURCES_OPTIONS + ")/" + RESOURCE_NAME_REGEX
71 | for filepath in self.files_handler.files_by_type(project_path, extension="xml"):
72 | # look-up for styles
73 | styles_references = self._style_usages_in_xml(filepath)
74 | resources = resources.union(styles_references)
75 |
76 | for line in self.files_handler.file_content(filepath):
77 | for result in re.finditer(xml_regex, line):
78 | resource_reference = ResourceReference(
79 | result.group().split("/")[-1],
80 | ResourceType[result.groups()[0]],
81 | )
82 | resources.add(resource_reference)
83 |
84 | code_regex = r"R\.(" + RESOURCES_OPTIONS + r")\." + RESOURCE_NAME_REGEX
85 | for filepath in self.files_handler.java_kt_files(project_path):
86 | for line in self.files_handler.file_content(filepath):
87 | for result in re.finditer(code_regex, line):
88 | resource_split = result.group().split(".")
89 | resource_type = ResourceType[resource_split[1]]
90 | resource_name = (
91 | resource_split[-1]
92 | if resource_type is not ResourceType.style
93 | else resource_split[-1].replace("_", ".")
94 | )
95 |
96 | resource_reference = ResourceReference(resource_name, resource_type)
97 | resources.add(resource_reference)
98 |
99 | return resources
100 |
101 | def _style_usages_in_xml(self, filepath):
102 | usages = set()
103 |
104 | style_usage_regexes = [
105 | # - @style/MyStyle
106 | r"@style/([A-Za-z\.]+)",
107 | #
108 | r'parent="([A-Za-z\.]+)"',
109 | #
110 | r'style name="([A-Za-z\.]+)\..*"',
111 | ]
112 |
113 | for line in self.files_handler.file_content(filepath):
114 | for regex in style_usage_regexes:
115 | match = re.search(regex, line)
116 | if match is not None:
117 | usages.add(ResourceReference(match.groups()[0], ResourceType.style))
118 |
119 | return usages
120 |
121 |
122 | class ResourcesModifier:
123 | def delete_resources(self, resources_list):
124 | for packaged_resource in resources_list:
125 | if packaged_resource.packaging_type is PackagingType.file:
126 | os.remove(packaged_resource.filepath)
127 | else:
128 | self._delete_resource_entry(packaged_resource)
129 |
130 | def _delete_resource_entry(self, packaged_resource):
131 | tree = ET.parse(packaged_resource.filepath)
132 | results = tree.findall(f'.//*[@name="{packaged_resource.resource.name}"]')
133 | for result in results:
134 | tree.getroot().remove(result)
135 | tree.write(packaged_resource.filepath, encoding="utf-8", xml_declaration=True)
136 |
137 |
138 | RESOURCE_NAME_REGEX = "[A-Za-z0-9_]+"
139 | RESOURCES_OPTIONS = "|".join([r.name for r in ResourceType])
140 |
--------------------------------------------------------------------------------
/android_resources_checker/validator.py:
--------------------------------------------------------------------------------
1 | class Validator:
2 | def validate(self, analysis):
3 | all_unused_resources = []
4 | for unused_resources in analysis.unused_resources.values():
5 | for resource in unused_resources:
6 | all_unused_resources.append(resource)
7 |
8 | if len(all_unused_resources) > 0:
9 | raise UnusedResourcesException(all_unused_resources)
10 |
11 |
12 | class UnusedResourcesException(Exception):
13 | def __init__(self, unused_resources):
14 | self.unused_resources = unused_resources
15 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | [[package]]
2 | name = "appdirs"
3 | version = "1.4.4"
4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
5 | category = "dev"
6 | optional = false
7 | python-versions = "*"
8 |
9 | [[package]]
10 | name = "atomicwrites"
11 | version = "1.4.0"
12 | description = "Atomic file writes."
13 | category = "main"
14 | optional = false
15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
16 |
17 | [[package]]
18 | name = "attrs"
19 | version = "20.3.0"
20 | description = "Classes Without Boilerplate"
21 | category = "main"
22 | optional = false
23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
24 |
25 | [package.extras]
26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
27 | docs = ["furo", "sphinx", "zope.interface"]
28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
30 |
31 | [[package]]
32 | name = "black"
33 | version = "20.8b1"
34 | description = "The uncompromising code formatter."
35 | category = "dev"
36 | optional = false
37 | python-versions = ">=3.6"
38 |
39 | [package.dependencies]
40 | appdirs = "*"
41 | click = ">=7.1.2"
42 | mypy-extensions = ">=0.4.3"
43 | pathspec = ">=0.6,<1"
44 | regex = ">=2020.1.8"
45 | toml = ">=0.10.1"
46 | typed-ast = ">=1.4.0"
47 | typing-extensions = ">=3.7.4"
48 |
49 | [package.extras]
50 | colorama = ["colorama (>=0.4.3)"]
51 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
52 |
53 | [[package]]
54 | name = "click"
55 | version = "7.1.2"
56 | description = "Composable command line interface toolkit"
57 | category = "main"
58 | optional = false
59 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
60 |
61 | [[package]]
62 | name = "colorama"
63 | version = "0.4.4"
64 | description = "Cross-platform colored terminal text."
65 | category = "main"
66 | optional = false
67 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
68 |
69 | [[package]]
70 | name = "commonmark"
71 | version = "0.9.1"
72 | description = "Python parser for the CommonMark Markdown spec"
73 | category = "main"
74 | optional = false
75 | python-versions = "*"
76 |
77 | [package.extras]
78 | test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
79 |
80 | [[package]]
81 | name = "coverage"
82 | version = "5.5"
83 | description = "Code coverage measurement for Python"
84 | category = "main"
85 | optional = false
86 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
87 |
88 | [package.extras]
89 | toml = ["toml"]
90 |
91 | [[package]]
92 | name = "iniconfig"
93 | version = "1.1.1"
94 | description = "iniconfig: brain-dead simple config-ini parsing"
95 | category = "main"
96 | optional = false
97 | python-versions = "*"
98 |
99 | [[package]]
100 | name = "mypy-extensions"
101 | version = "0.4.3"
102 | description = "Experimental type system extensions for programs checked with the mypy typechecker."
103 | category = "dev"
104 | optional = false
105 | python-versions = "*"
106 |
107 | [[package]]
108 | name = "packaging"
109 | version = "20.9"
110 | description = "Core utilities for Python packages"
111 | category = "main"
112 | optional = false
113 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
114 |
115 | [package.dependencies]
116 | pyparsing = ">=2.0.2"
117 |
118 | [[package]]
119 | name = "pathspec"
120 | version = "0.8.1"
121 | description = "Utility library for gitignore style pattern matching of file paths."
122 | category = "dev"
123 | optional = false
124 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
125 |
126 | [[package]]
127 | name = "pluggy"
128 | version = "0.13.1"
129 | description = "plugin and hook calling mechanisms for python"
130 | category = "main"
131 | optional = false
132 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
133 |
134 | [package.extras]
135 | dev = ["pre-commit", "tox"]
136 |
137 | [[package]]
138 | name = "py"
139 | version = "1.10.0"
140 | description = "library with cross-python path, ini-parsing, io, code, log facilities"
141 | category = "main"
142 | optional = false
143 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
144 |
145 | [[package]]
146 | name = "pygments"
147 | version = "2.8.1"
148 | description = "Pygments is a syntax highlighting package written in Python."
149 | category = "main"
150 | optional = false
151 | python-versions = ">=3.5"
152 |
153 | [[package]]
154 | name = "pyparsing"
155 | version = "2.4.7"
156 | description = "Python parsing module"
157 | category = "main"
158 | optional = false
159 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
160 |
161 | [[package]]
162 | name = "pytest"
163 | version = "6.2.2"
164 | description = "pytest: simple powerful testing with Python"
165 | category = "main"
166 | optional = false
167 | python-versions = ">=3.6"
168 |
169 | [package.dependencies]
170 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
171 | attrs = ">=19.2.0"
172 | colorama = {version = "*", markers = "sys_platform == \"win32\""}
173 | iniconfig = "*"
174 | packaging = "*"
175 | pluggy = ">=0.12,<1.0.0a1"
176 | py = ">=1.8.2"
177 | toml = "*"
178 |
179 | [package.extras]
180 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
181 |
182 | [[package]]
183 | name = "pytest-cov"
184 | version = "2.11.1"
185 | description = "Pytest plugin for measuring coverage."
186 | category = "main"
187 | optional = false
188 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
189 |
190 | [package.dependencies]
191 | coverage = ">=5.2.1"
192 | pytest = ">=4.6"
193 |
194 | [package.extras]
195 | testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"]
196 |
197 | [[package]]
198 | name = "regex"
199 | version = "2021.3.17"
200 | description = "Alternative regular expression module, to replace re."
201 | category = "dev"
202 | optional = false
203 | python-versions = "*"
204 |
205 | [[package]]
206 | name = "rich"
207 | version = "10.0.0"
208 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
209 | category = "main"
210 | optional = false
211 | python-versions = ">=3.6,<4.0"
212 |
213 | [package.dependencies]
214 | colorama = ">=0.4.0,<0.5.0"
215 | commonmark = ">=0.9.0,<0.10.0"
216 | pygments = ">=2.6.0,<3.0.0"
217 | typing-extensions = ">=3.7.4,<4.0.0"
218 |
219 | [package.extras]
220 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
221 |
222 | [[package]]
223 | name = "toml"
224 | version = "0.10.2"
225 | description = "Python Library for Tom's Obvious, Minimal Language"
226 | category = "main"
227 | optional = false
228 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
229 |
230 | [[package]]
231 | name = "typed-ast"
232 | version = "1.4.2"
233 | description = "a fork of Python 2 and 3 ast modules with type comment support"
234 | category = "dev"
235 | optional = false
236 | python-versions = "*"
237 |
238 | [[package]]
239 | name = "typing-extensions"
240 | version = "3.7.4.3"
241 | description = "Backported and Experimental Type Hints for Python 3.5+"
242 | category = "main"
243 | optional = false
244 | python-versions = "*"
245 |
246 | [metadata]
247 | lock-version = "1.1"
248 | python-versions = "^3.8"
249 | content-hash = "5a4e7ce8bd9fdb9742614314108f3bd4658816fe185b95e5e3436f64f500b897"
250 |
251 | [metadata.files]
252 | appdirs = [
253 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
254 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
255 | ]
256 | atomicwrites = [
257 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
258 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
259 | ]
260 | attrs = [
261 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
262 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
263 | ]
264 | black = [
265 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
266 | ]
267 | click = [
268 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
269 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
270 | ]
271 | colorama = [
272 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
273 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
274 | ]
275 | commonmark = [
276 | {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
277 | {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
278 | ]
279 | coverage = [
280 | {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
281 | {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
282 | {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"},
283 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"},
284 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"},
285 | {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"},
286 | {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"},
287 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"},
288 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"},
289 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"},
290 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"},
291 | {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"},
292 | {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"},
293 | {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"},
294 | {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"},
295 | {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"},
296 | {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"},
297 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"},
298 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"},
299 | {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"},
300 | {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"},
301 | {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"},
302 | {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"},
303 | {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"},
304 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"},
305 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"},
306 | {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"},
307 | {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"},
308 | {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"},
309 | {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"},
310 | {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"},
311 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"},
312 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"},
313 | {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"},
314 | {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"},
315 | {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"},
316 | {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"},
317 | {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"},
318 | {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"},
319 | {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"},
320 | {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"},
321 | {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"},
322 | {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"},
323 | {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"},
324 | {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"},
325 | {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"},
326 | {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"},
327 | {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"},
328 | {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"},
329 | {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"},
330 | {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
331 | {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
332 | ]
333 | iniconfig = [
334 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
335 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
336 | ]
337 | mypy-extensions = [
338 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
339 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
340 | ]
341 | packaging = [
342 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
343 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
344 | ]
345 | pathspec = [
346 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
347 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
348 | ]
349 | pluggy = [
350 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
351 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
352 | ]
353 | py = [
354 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
355 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
356 | ]
357 | pygments = [
358 | {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"},
359 | {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"},
360 | ]
361 | pyparsing = [
362 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
363 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
364 | ]
365 | pytest = [
366 | {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"},
367 | {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"},
368 | ]
369 | pytest-cov = [
370 | {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"},
371 | {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"},
372 | ]
373 | regex = [
374 | {file = "regex-2021.3.17-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6"},
375 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0"},
376 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c"},
377 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa"},
378 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14"},
379 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f"},
380 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd"},
381 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e"},
382 | {file = "regex-2021.3.17-cp36-cp36m-win32.whl", hash = "sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3"},
383 | {file = "regex-2021.3.17-cp36-cp36m-win_amd64.whl", hash = "sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5"},
384 | {file = "regex-2021.3.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90"},
385 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f"},
386 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689"},
387 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932"},
388 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa"},
389 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df"},
390 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce"},
391 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643"},
392 | {file = "regex-2021.3.17-cp37-cp37m-win32.whl", hash = "sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be"},
393 | {file = "regex-2021.3.17-cp37-cp37m-win_amd64.whl", hash = "sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb"},
394 | {file = "regex-2021.3.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18"},
395 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5"},
396 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe"},
397 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578"},
398 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d"},
399 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5"},
400 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c"},
401 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d"},
402 | {file = "regex-2021.3.17-cp38-cp38-win32.whl", hash = "sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf"},
403 | {file = "regex-2021.3.17-cp38-cp38-win_amd64.whl", hash = "sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa"},
404 | {file = "regex-2021.3.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3"},
405 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_i686.whl", hash = "sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a"},
406 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c"},
407 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139"},
408 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e"},
409 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba"},
410 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106"},
411 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7"},
412 | {file = "regex-2021.3.17-cp39-cp39-win32.whl", hash = "sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd"},
413 | {file = "regex-2021.3.17-cp39-cp39-win_amd64.whl", hash = "sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38"},
414 | {file = "regex-2021.3.17.tar.gz", hash = "sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68"},
415 | ]
416 | rich = [
417 | {file = "rich-10.0.0-py3-none-any.whl", hash = "sha256:01b3fcc305ae71b9ade4a645b6e371d395c6cd9ba52dcf180bfba69ef05c13b5"},
418 | {file = "rich-10.0.0.tar.gz", hash = "sha256:4674bd3056a72bb282ad581e3f8092dc110cdcc456b5ba76e34965cb85a69724"},
419 | ]
420 | toml = [
421 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
422 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
423 | ]
424 | typed-ast = [
425 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"},
426 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"},
427 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"},
428 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"},
429 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"},
430 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"},
431 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"},
432 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"},
433 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"},
434 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"},
435 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"},
436 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"},
437 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"},
438 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"},
439 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"},
440 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"},
441 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"},
442 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"},
443 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"},
444 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"},
445 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"},
446 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"},
447 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"},
448 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"},
449 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"},
450 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"},
451 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"},
452 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"},
453 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"},
454 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"},
455 | ]
456 | typing-extensions = [
457 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
458 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
459 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
460 | ]
461 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "android-resources-checker"
3 | version = "0.0.10"
4 | description = "Check if your android resources are being unused."
5 | authors = ["Fabio Carballo "]
6 | license = "MIT"
7 | readme = "README.md"
8 | homepage = "https://github.com/fabiocarballo/android-resources-checker"
9 | repository = "https://github.com/fabiocarballo/android-resources-checker"
10 | include = ["LICENSE.md"]
11 |
12 | [tool.poetry.dependencies]
13 | python = "^3.8"
14 | rich = "^10.0.0"
15 | click = "^7.1.2"
16 |
17 | [tool.poetry.dev-dependencies]
18 | black = "^20.8b1"
19 | pytest = "6.2.2"
20 | pytest-cov = "^2.11.1"
21 |
22 | [tool.poetry.scripts]
23 | android-resources-checker = "android_resources_checker:main"
24 |
25 | [build-system]
26 | requires = ["poetry-core>=1.0.0"]
27 | build-backend = "poetry.core.masonry.api"
28 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fbcbl/android-resources-checker/b4c35ab14789599775423bf4519b7348e0d1ce9b/tests/__init__.py
--------------------------------------------------------------------------------
/tests/fixtures/dummy-anim.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-class.kt:
--------------------------------------------------------------------------------
1 | class MyClass {
2 |
3 | fun func1() {
4 | val resource = R.drawable.drawable_used_programatically
5 |
6 | val textColor = if(condition) context.getColor(R.color.programatic_1) else context.getColor(R.color.programatic_2)
7 | }
8 | }
--------------------------------------------------------------------------------
/tests/fixtures/dummy-color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 1dp
4 | 10dp
5 | 20dp
6 |
7 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-drawable.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
8 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
21 |
22 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-styles.kt:
--------------------------------------------------------------------------------
1 | class MyClass {
2 |
3 | fun func1() {
4 | val style = if(condition) R.style.MyTheme_ThemeA_ThemeB else R.style.OtherTheme;
5 | }
6 | }
--------------------------------------------------------------------------------
/tests/fixtures/dummy-styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
21 |
--------------------------------------------------------------------------------
/tests/fixtures/dummy-values-color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 | #ABCDEF
5 |
--------------------------------------------------------------------------------
/tests/test_reporting.py:
--------------------------------------------------------------------------------
1 | import csv
2 | from typing import Any, Union
3 |
4 | import pytest
5 | from rich.console import Console, JustifyMethod, OverflowMethod
6 | from rich.style import Style
7 | from rich.table import Table
8 |
9 | from android_resources_checker.models import (
10 | AnalysisBreakdown,
11 | ResourceType,
12 | PackagedResource,
13 | ResourceReference,
14 | PackagingType,
15 | )
16 | from android_resources_checker.reporting import (
17 | CsvAnalysisReporter,
18 | HEADER_RESOURCE_TYPE,
19 | HEADER_NUM_UNUSED_RESOURCES,
20 | HEADER_NUM_USED_RESOURCES,
21 | StdoutReporter,
22 | HEADER_RESOURCE_SIZE,
23 | HEADER_RESOURCE_LOCATION,
24 | HEADER_RESOURCE_NAME,
25 | )
26 |
27 |
28 | def _create_test_packaged_resource(
29 | name, type, packaging_type=PackagingType.file, size=10
30 | ):
31 | return PackagedResource(
32 | ResourceReference(name, type), packaging_type, f"path/to/{name}.xml", size
33 | )
34 |
35 |
36 | def test_csv_report_usage_breakdown(tmpdir, breakdown_for_usage):
37 | # Arrange
38 | reports_dir = tmpdir.mkdir("reports")
39 | reporter = CsvAnalysisReporter(Console(), reports_dir)
40 |
41 | # Act
42 | reporter.report(breakdown_for_usage)
43 |
44 | # Assert
45 | expected_csv_files = [
46 | [HEADER_RESOURCE_TYPE, HEADER_NUM_USED_RESOURCES, HEADER_NUM_UNUSED_RESOURCES],
47 | ["anim", "0", "1"],
48 | ["color", "1", "0"],
49 | ["dimen", "0", "0"],
50 | ["drawable", "3", "2"],
51 | ["raw", "0", "0"],
52 | ["string", "0", "0"],
53 | ["style", "0", "0"],
54 | ]
55 | file_lines = []
56 | with (open(f"{tmpdir}/reports/resources_usage.csv")) as f:
57 | reader = csv.reader(f, delimiter=";")
58 | for row in reader:
59 | file_lines.append(row)
60 |
61 | assert file_lines == expected_csv_files
62 |
63 |
64 | def test_csv_report_unused_resources(tmpdir, breakdown_for_unused_resources_list):
65 | # Arrange
66 | reports_dir = tmpdir.mkdir("reports")
67 | reporter = CsvAnalysisReporter(Console(), reports_dir)
68 |
69 | # Act
70 | reporter.report_unused_resources_list(breakdown_for_unused_resources_list)
71 |
72 | # Assert
73 | expected_csv_files = [
74 | [HEADER_RESOURCE_NAME, HEADER_RESOURCE_LOCATION, HEADER_RESOURCE_SIZE],
75 | ["R.drawable.name1", "path/to/name1.xml", "1.95 kb"],
76 | ["R.drawable.name3", "path/to/name3.xml", "0.01 kb"],
77 | ["R.color.color1", "path/to/color1.xml", "0.00 kb"],
78 | ["R.color.color_selector", "path/to/color_selector.xml", "0.01 kb"],
79 | ["R.anim.anim1", "path/to/anim1.xml", "0.02 kb"],
80 | ]
81 | file_lines = []
82 | with (open(f"{tmpdir}/reports/unused_resources.csv")) as f:
83 | reader = csv.reader(f, delimiter=";")
84 | for row in reader:
85 | file_lines.append(row)
86 |
87 | assert file_lines == expected_csv_files
88 |
89 |
90 | class FakeConsole(Console):
91 | def __init__(self):
92 | super().__init__()
93 | self.calls = []
94 |
95 | def print(
96 | self,
97 | *objects: Any,
98 | sep=" ",
99 | end="\n",
100 | style: Union[str, Style] = None,
101 | justify: JustifyMethod = None,
102 | overflow: OverflowMethod = None,
103 | no_wrap: bool = None,
104 | emoji: bool = None,
105 | markup: bool = None,
106 | highlight: bool = None,
107 | width: int = None,
108 | height: int = None,
109 | crop: bool = True,
110 | soft_wrap: bool = None,
111 | ) -> None:
112 | self.calls.append(*objects)
113 |
114 |
115 | def test_stdout_unused_resources_list(breakdown_for_unused_resources_list):
116 | # Arrange
117 | console = FakeConsole()
118 | reporter = StdoutReporter(console)
119 |
120 | # Act
121 | reporter.report_unused_resources_list(breakdown_for_unused_resources_list)
122 |
123 | # Assert
124 | expected_table = Table(show_header=True, header_style="bold magenta")
125 | expected_table.pad_edge = True
126 | expected_table.add_column(HEADER_RESOURCE_NAME)
127 | expected_table.add_column(HEADER_RESOURCE_LOCATION)
128 | expected_table.add_column(HEADER_RESOURCE_SIZE)
129 | expected_table.add_row(*["R.drawable.name1", "path/to/name1.xml", "1.95 kb"])
130 | expected_table.add_row(*["R.drawable.name3", "path/to/name3.xml", "0.01 kb"])
131 | expected_table.add_row(*["R.color.color1", "path/to/color1.xml", "0.00 kb"])
132 | expected_table.add_row(
133 | *["R.color.color_selector", "path/to/color_selector.xml", "0.01 kb"]
134 | )
135 | expected_table.add_row(*["R.anim.anim1", "path/to/anim1.xml", "0.02 kb"])
136 |
137 | expected_stdout = [
138 | "\n[bold green]Unused Resources List[/bold green]",
139 | expected_table,
140 | ]
141 |
142 | assert console.calls[0] == expected_stdout[0]
143 | assert console.calls[1].columns == expected_stdout[1].columns
144 | assert console.calls[1].rows == expected_stdout[1].rows
145 |
146 |
147 | def test_stdout_report_usage_breakdown(breakdown_for_usage):
148 | # Arrange
149 | console = FakeConsole()
150 | reporter = StdoutReporter(console)
151 |
152 | # Act
153 | reporter.report(breakdown_for_usage)
154 |
155 | # Assert
156 | expected_table = Table(show_header=True, header_style="bold magenta")
157 | expected_table.pad_edge = True
158 | expected_table.add_column(HEADER_RESOURCE_TYPE)
159 | expected_table.add_column(HEADER_NUM_USED_RESOURCES)
160 | expected_table.add_column(HEADER_NUM_UNUSED_RESOURCES)
161 | expected_table.add_row(*["anim", "0", "1"])
162 | expected_table.add_row(*["color", "1", "0"])
163 | expected_table.add_row(*["dimen", "0", "0"])
164 | expected_table.add_row(*["drawable", "3", "2"])
165 | expected_table.add_row(*["raw", "0", "0"])
166 | expected_table.add_row(*["string", "0", "0"])
167 | expected_table.add_row(*["style", "0", "0"])
168 |
169 | expected_stdout = ["\n[bold green]Resources Usage[/bold green]", expected_table]
170 |
171 | assert console.calls[0] == expected_stdout[0]
172 | assert console.calls[1].columns == expected_stdout[1].columns
173 | assert console.calls[1].rows == expected_stdout[1].rows
174 |
175 |
176 | @pytest.fixture
177 | def breakdown_for_usage():
178 | return AnalysisBreakdown(
179 | project_name="test-project",
180 | used_resources={
181 | ResourceType.drawable: {
182 | _create_test_packaged_resource("name1", ResourceType.drawable),
183 | _create_test_packaged_resource("name2", ResourceType.drawable),
184 | _create_test_packaged_resource("name3", ResourceType.drawable),
185 | },
186 | ResourceType.color: {
187 | _create_test_packaged_resource("color1", ResourceType.color)
188 | },
189 | },
190 | unused_resources={
191 | ResourceType.drawable: {
192 | _create_test_packaged_resource("name1", ResourceType.drawable),
193 | _create_test_packaged_resource("name3", ResourceType.drawable),
194 | },
195 | ResourceType.color: {},
196 | ResourceType.anim: {
197 | _create_test_packaged_resource("anim1", ResourceType.anim)
198 | },
199 | },
200 | unused_size_bytes=55,
201 | )
202 |
203 |
204 | @pytest.fixture
205 | def breakdown_for_unused_resources_list():
206 | return AnalysisBreakdown(
207 | project_name="test-project",
208 | used_resources={
209 | ResourceType.drawable: {
210 | _create_test_packaged_resource("name3", ResourceType.drawable),
211 | },
212 | ResourceType.color: {
213 | _create_test_packaged_resource("color1", ResourceType.color)
214 | },
215 | },
216 | unused_resources={
217 | ResourceType.drawable: {
218 | _create_test_packaged_resource(
219 | "name1", ResourceType.drawable, size=2000
220 | ),
221 | _create_test_packaged_resource("name3", ResourceType.drawable, size=10),
222 | },
223 | ResourceType.color: {
224 | _create_test_packaged_resource(
225 | "color1", ResourceType.color, PackagingType.entry, 1
226 | ),
227 | _create_test_packaged_resource(
228 | "color_selector", ResourceType.color, PackagingType.file, 10
229 | ),
230 | },
231 | ResourceType.anim: {
232 | _create_test_packaged_resource(
233 | "anim1", ResourceType.anim, PackagingType.file, 20
234 | )
235 | },
236 | },
237 | unused_size_bytes=55,
238 | )
239 |
--------------------------------------------------------------------------------
/tests/test_resources_analyzer.py:
--------------------------------------------------------------------------------
1 | from android_resources_checker.analyzer import ResourcesAnalyzer
2 | from android_resources_checker.models import (
3 | AnalysisBreakdown,
4 | ResourceReference,
5 | ResourceType,
6 | PackagedResource,
7 | PackagingType,
8 | )
9 |
10 |
11 | def _test_packaged_resource(
12 | index, resource_type, size=10, packaging_type=PackagingType.file
13 | ):
14 | name = f"{resource_type.name}_{index}"
15 |
16 | return PackagedResource(
17 | ResourceReference(name, resource_type), packaging_type, f"/path/{name}", size
18 | )
19 |
20 |
21 | def test_analyze_resources_usage():
22 | # Given
23 | name = "test"
24 | color_0 = _test_packaged_resource(0, ResourceType.color)
25 | color_1 = _test_packaged_resource(1, ResourceType.color, size=25)
26 | color_2 = _test_packaged_resource(
27 | 2,
28 | ResourceType.color,
29 | )
30 | drawable_0 = _test_packaged_resource(0, ResourceType.drawable, size=50)
31 | drawable_1 = _test_packaged_resource(1, ResourceType.drawable, size=55)
32 | drawable_2 = _test_packaged_resource(2, ResourceType.drawable, size=40)
33 | analyzer = ResourcesAnalyzer()
34 |
35 | # Act
36 | breakdown = analyzer.analyze(
37 | name=name,
38 | usage_references=[
39 | drawable_0.resource,
40 | color_0.resource,
41 | color_2.resource,
42 | drawable_2.resource,
43 | ],
44 | packaged_resources=[
45 | color_0,
46 | color_1,
47 | color_2,
48 | drawable_0,
49 | drawable_1,
50 | drawable_2,
51 | ],
52 | )
53 |
54 | # Assert
55 | expected_breakdown = AnalysisBreakdown(
56 | name,
57 | used_resources={
58 | ResourceType.anim: set(),
59 | ResourceType.color: {color_0, color_2},
60 | ResourceType.dimen: set(),
61 | ResourceType.drawable: {drawable_0, drawable_2},
62 | ResourceType.raw: set(),
63 | ResourceType.string: set(),
64 | ResourceType.style: set(),
65 | },
66 | unused_resources={
67 | ResourceType.anim: set(),
68 | ResourceType.color: {color_1},
69 | ResourceType.dimen: set(),
70 | ResourceType.drawable: {drawable_1},
71 | ResourceType.raw: set(),
72 | ResourceType.string: set(),
73 | ResourceType.style: set(),
74 | },
75 | unused_size_bytes=80,
76 | )
77 |
78 | assert breakdown == expected_breakdown
79 |
--------------------------------------------------------------------------------
/tests/test_resources_fetcher.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from android_resources_checker.files import FilesHandler
4 | from android_resources_checker.models import (
5 | PackagedResource,
6 | ResourceReference,
7 | ResourceType,
8 | PackagingType,
9 | )
10 | from android_resources_checker.resources import ResourcesFetcher
11 | import xml.etree.ElementTree as ET
12 |
13 | TEST_DIR = os.path.dirname(os.path.abspath(__file__))
14 |
15 |
16 | class FakeFilesHandler(FilesHandler):
17 | def __init__(self, root_path, fake_config):
18 | self.root_path = root_path
19 | self.fake_config = fake_config
20 |
21 | def java_kt_files(self, root):
22 | return [
23 | f
24 | for f in self.fake_config.keys()
25 | if f.endswith(".kt") or f.endswith(".java")
26 | ]
27 |
28 | def resource_files(self, root, extension="*"):
29 | if self.root_path == root:
30 | return [
31 | f
32 | for f in self.fake_config.keys()
33 | if extension == "*" or f.endswith(extension)
34 | ]
35 | else:
36 | return []
37 |
38 | def files_by_type(self, root, extension="*"):
39 | if self.root_path == root:
40 | return [
41 | f
42 | for f in self.fake_config.keys()
43 | if extension == "*" or f.endswith(extension)
44 | ]
45 | else:
46 | return []
47 |
48 | def file_size(self, filepath):
49 | return self.fake_config[filepath]["size"]
50 |
51 | def file_content(self, filepath):
52 | return self.fake_config[filepath]["content"]
53 |
54 | def xml_tree(self, filepath):
55 | return self.fake_config[filepath]["xml_content"]
56 |
57 |
58 | def test_fetch_packaged_resources():
59 | # Given
60 | fake_files_handler = FakeFilesHandler(
61 | "root",
62 | {
63 | "path/to/drawable/file1.xml": {
64 | "size": 10,
65 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-drawable.xml"),
66 | },
67 | "path/to/anim/file2.xml": {
68 | "size": 20,
69 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-anim.xml"),
70 | },
71 | "path/to/color/file3.xml": {
72 | "size": 30,
73 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-color.xml"),
74 | },
75 | "path/to/values/file4.xml": {
76 | "size": 40,
77 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-values-color.xml"),
78 | },
79 | "path/to/raw/lottie.json": {"size": 500},
80 | },
81 | )
82 | resources_fetcher = ResourcesFetcher(fake_files_handler)
83 |
84 | # Act
85 | resources = resources_fetcher.fetch_packaged_resources("root")
86 |
87 | # Assert
88 | assert resources == {
89 | PackagedResource(
90 | ResourceReference("file1", ResourceType.drawable),
91 | PackagingType.file,
92 | "path/to/drawable/file1.xml",
93 | 10,
94 | ),
95 | PackagedResource(
96 | ResourceReference("file2", ResourceType.anim),
97 | PackagingType.file,
98 | "path/to/anim/file2.xml",
99 | 20,
100 | ),
101 | PackagedResource(
102 | ResourceReference("file3", ResourceType.color),
103 | PackagingType.file,
104 | "path/to/color/file3.xml",
105 | 30,
106 | ),
107 | PackagedResource(
108 | ResourceReference("color_entry_0", ResourceType.color),
109 | PackagingType.entry,
110 | "path/to/values/file4.xml",
111 | 0,
112 | ),
113 | PackagedResource(
114 | ResourceReference("color_entry_1", ResourceType.color),
115 | PackagingType.entry,
116 | "path/to/values/file4.xml",
117 | 0,
118 | ),
119 | PackagedResource(
120 | ResourceReference("lottie", ResourceType.raw),
121 | PackagingType.file,
122 | "path/to/raw/lottie.json",
123 | 500,
124 | ),
125 | }
126 |
127 |
128 | def test_fetch_used_resources():
129 | # Arrange
130 | fake_files_handler = FakeFilesHandler(
131 | "root",
132 | {
133 | "path/to/values/dummy-layout.xml": {
134 | "content": open(f"{TEST_DIR}/fixtures/dummy-layout.xml").readlines()
135 | },
136 | "path/to/dummy.kt": {
137 | "content": open(f"{TEST_DIR}/fixtures/dummy-class.kt").readlines()
138 | },
139 | },
140 | )
141 |
142 | resources_fetcher = ResourcesFetcher(fake_files_handler)
143 |
144 | # Act
145 | references = resources_fetcher.fetch_used_resources("root")
146 |
147 | # Assert
148 | assert references == {
149 | ResourceReference("background_white", ResourceType.drawable),
150 | ResourceReference("spacing_small", ResourceType.dimen),
151 | ResourceReference("dummy_string", ResourceType.string),
152 | ResourceReference("red", ResourceType.color),
153 | ResourceReference("spacing_normal", ResourceType.dimen),
154 | ResourceReference("background_red", ResourceType.drawable),
155 | ResourceReference("file3", ResourceType.color),
156 | ResourceReference("spacing_large", ResourceType.dimen),
157 | ResourceReference("drawable_used_programatically", ResourceType.drawable),
158 | ResourceReference("programatic_1", ResourceType.color),
159 | ResourceReference("programatic_2", ResourceType.color),
160 | }
161 |
162 |
163 | def test_styles_xml_usage_detection():
164 | # Arrange
165 | fake_files_handler = FakeFilesHandler(
166 | "root",
167 | {
168 | "path/to/values/dummy-styles.xml": {
169 | "content": open(f"{TEST_DIR}/fixtures/dummy-styles.xml").readlines()
170 | },
171 | },
172 | )
173 |
174 | resources_fetcher = ResourcesFetcher(fake_files_handler)
175 |
176 | # Act
177 | references = resources_fetcher.fetch_used_resources("root")
178 |
179 | # Assert
180 | expected_used_styles = {
181 | ResourceReference("BaseThemeA", ResourceType.style),
182 | ResourceReference("BaseThemeA.ThemeA", ResourceType.style),
183 | ResourceReference("BaseThemeB", ResourceType.style),
184 | ResourceReference("BaseThemeB.ThemeB", ResourceType.style),
185 | ResourceReference("BaseThemeC.BaseThemeD", ResourceType.style),
186 | ResourceReference("BaseThemeG", ResourceType.style),
187 | ResourceReference("BaseThemeG.ThemeG", ResourceType.style),
188 | }
189 |
190 | assert references == expected_used_styles
191 |
192 |
193 | def test_styles_programatic_usage_detection():
194 | # Arrange
195 | fake_files_handler = FakeFilesHandler(
196 | "root",
197 | {
198 | "path/to/MyClass.kt": {
199 | "content": open(f"{TEST_DIR}/fixtures/dummy-styles.kt").readlines()
200 | },
201 | },
202 | )
203 |
204 | resources_fetcher = ResourcesFetcher(fake_files_handler)
205 |
206 | # Act
207 | references = resources_fetcher.fetch_used_resources("root")
208 |
209 | # Assert
210 | expected_used_styles = {
211 | ResourceReference("MyTheme.ThemeA.ThemeB", ResourceType.style),
212 | ResourceReference("OtherTheme", ResourceType.style),
213 | }
214 |
215 | assert references == expected_used_styles
216 |
--------------------------------------------------------------------------------
/tests/test_validator.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from android_resources_checker.models import (
4 | AnalysisBreakdown,
5 | ResourceType,
6 | PackagedResource,
7 | ResourceReference,
8 | PackagingType,
9 | )
10 | from android_resources_checker.validator import Validator, UnusedResourcesException
11 |
12 |
13 | def test_validator_throws_exception_when_there_are_unused_resources():
14 | validator = Validator()
15 |
16 | analysis = AnalysisBreakdown(
17 | "proj-test",
18 | {},
19 | {
20 | ResourceType.drawable: {
21 | PackagedResource(
22 | ResourceReference("resource", ResourceType.drawable),
23 | PackagingType.entry,
24 | "filepath/to/file",
25 | 10,
26 | )
27 | }
28 | },
29 | 0,
30 | )
31 |
32 | with pytest.raises(UnusedResourcesException):
33 | validator.validate(analysis)
34 |
35 |
36 | def test_validator_does_not_throw_exception_where_there_are_no_unused_resources():
37 | validator = Validator()
38 |
39 | analysis = AnalysisBreakdown("proj-test", {}, {ResourceType.drawable: set()}, 0)
40 |
41 | validator.validate(analysis)
42 |
--------------------------------------------------------------------------------