Skip to content

Commit 50eacb7

Browse files
authored
Add clean command to fireci (#6563)
Per [b/381270432](https://b.corp.google.com/issues/381270432), This adds a command to our `fireci` cli tool that can work as an alternative to `gradle clean`. There seems to be some dependency issues preventing `gradle clean` from completely correctly, but with the usage of `fireci`- we can avoid working within gradle entirely. Furthermore, this command comes with flags for deeper cleaning of gradle's caches. I've also updated the dependencies that fireci was using, as I ran into a variety of issues trying to use it "as-is" on an M1 machine. The updated dependencies seem to include fixes for such issues. Command usage: ```sh fireci clean --help ```
1 parent 7a82efd commit 50eacb7

File tree

7 files changed

+224
-16
lines changed

7 files changed

+224
-16
lines changed

ci/README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This directory contains tooling used to run Continuous Integration tasks.
44

55
## Prerequisites
66

7-
- Requires python3.5+ and setuptools to be installed.
7+
- Requires python3.9+ and setuptools to be installed.
88

99
## Setup
1010

@@ -22,3 +22,26 @@ This directory contains tooling used to run Continuous Integration tasks.
2222
```
2323
fireci --help
2424
```
25+
26+
## Uninstall
27+
28+
If you run into any issues and need to re-install, or uninstall the package, you can do so
29+
by uninstalling the `fireci` package.
30+
31+
```shell
32+
pip3 uninstall fireci -y
33+
```
34+
35+
## Debug
36+
37+
By default, if you're not running `fireci` within the context of CI, the minimum log level is set
38+
to `INFO`.
39+
40+
To manually set the level to `DEBUG`, you can use the `--debug` flag.
41+
42+
```shell
43+
fireci --debug clean
44+
```
45+
46+
> ![NOTE]
47+
> The `--debug` flag must come _before_ the command.

ci/fireci/fireci/ci_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import os
1717
import subprocess
1818

19+
from typing import List, Tuple, Union
20+
1921
_logger = logging.getLogger('fireci.ci_utils')
2022

2123

@@ -61,3 +63,28 @@ def gcloud_identity_token():
6163
"""Returns an identity token with the current gcloud service account."""
6264
result = subprocess.run(['gcloud', 'auth', 'print-identity-token'], stdout=subprocess.PIPE, check=True)
6365
return result.stdout.decode('utf-8').strip()
66+
67+
def get_projects(file_path: str = "subprojects.cfg") -> List[str]:
68+
"""Parses the specified file for a list of projects in the repo."""
69+
with open(file_path, 'r') as file:
70+
stripped_lines = [line.strip() for line in file]
71+
return [line for line in stripped_lines if line and not line.startswith('#')]
72+
73+
def counts(arr: List[Union[bool, int]]) -> Tuple[int, int]:
74+
"""Given an array of booleans and ints, returns a tuple mapping of [true, false].
75+
Positive int values add to the `true` count while values less than one add to `false`.
76+
"""
77+
true_count = 0
78+
false_count = 0
79+
for value in arr:
80+
if isinstance(value, bool):
81+
if value:
82+
true_count += 1
83+
else:
84+
false_count += 1
85+
elif value >= 1:
86+
true_count += value
87+
else:
88+
false_count += abs(value) if value < 0 else 1
89+
90+
return true_count, false_count

ci/fireci/fireci/dir_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
import contextlib
1616
import logging
1717
import os
18+
import pathlib
19+
import shutil
20+
import glob
1821

1922
_logger = logging.getLogger('fireci.dir_utils')
2023

@@ -30,3 +33,27 @@ def chdir(directory):
3033
finally:
3134
_logger.debug(f'Restoring directory to: {original_dir} ...')
3235
os.chdir(original_dir)
36+
37+
def rmdir(path: str) -> bool:
38+
"""Recursively deletes a directory, and returns a boolean indicating if the dir was deleted."""
39+
dir = pathlib.Path(path)
40+
if not dir.exists():
41+
_logger.debug(f"Directory already deleted: {dir}")
42+
return False
43+
44+
_logger.debug(f"Deleting directory: {dir}")
45+
shutil.rmtree(dir)
46+
return True
47+
48+
def rmglob(pattern: str) -> int:
49+
"""Deletes all files that match a given pattern, and returns the amount of (root) files deleted"""
50+
files = glob.glob(os.path.expanduser(pattern))
51+
for file in files:
52+
path = pathlib.Path(file)
53+
if path.is_dir():
54+
rmdir(file)
55+
else:
56+
_logger.debug(f"Deleting file: {path}")
57+
os.remove(path)
58+
59+
return len(files)

ci/fireci/fireci/internal.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ class _CommonOptions:
5858

5959

6060
@click.group()
61+
@click.option(
62+
'--debug/--no-debug',
63+
help='Set the min loglevel to debug.',
64+
default=False
65+
)
6166
@click.option(
6267
'--artifact-target-dir',
6368
default='_artifacts',
@@ -83,7 +88,7 @@ def main(options, **kwargs):
8388
setattr(options, k, v)
8489

8590

86-
def ci_command(name=None, cls=click.Command, group=main):
91+
def ci_command(name=None, cls=click.Command, group=main, epilog=None):
8792
"""Decorator to use for CI commands.
8893
8994
The differences from the standard @click.command are:
@@ -94,15 +99,19 @@ def ci_command(name=None, cls=click.Command, group=main):
9499
:param name: Optional name of the task. Defaults to the function name that is decorated with this decorator.
95100
:param cls: Specifies whether the func is a command or a command group. Defaults to `click.Command`.
96101
:param group: Specifies the group the command belongs to. Defaults to the `main` command group.
102+
:param epilog: Specifies epilog text to show at the end of the help text.
97103
"""
98104

99105
def ci_command(f):
100106
actual_name = f.__name__ if name is None else name
101107

102-
@click.command(name=actual_name, cls=cls, help=f.__doc__)
108+
@click.command(name=actual_name, cls=cls, help=f.__doc__, epilog=epilog)
103109
@_pass_options
104110
@click.pass_context
105111
def new_func(ctx, options, *args, **kwargs):
112+
if options.debug:
113+
logging.getLogger('fireci').setLevel(logging.DEBUG)
114+
106115
with _artifact_handler(
107116
options.artifact_target_dir,
108117
options.artifact_patterns,

ci/fireci/fireci/main.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
from .internal import main
2121

2222
# Unnecessary on CI as GitHub Actions provides them already.
23-
asctime_place_holder = '' if os.getenv('CI') else '%(asctime)s '
23+
is_ci = os.getenv('CI')
24+
asctime_place_holder = '' if is_ci else '%(asctime)s '
2425
log_format = f'[%(levelname).1s] {asctime_place_holder}%(name)s: %(message)s'
2526
logging.basicConfig(
2627
datefmt='%Y-%m-%d %H:%M:%S %z %Z',
2728
format=log_format,
2829
level=logging.INFO,
2930
)
30-
logging.getLogger('fireci').setLevel(logging.DEBUG)
31+
32+
level = logging.DEBUG if is_ci else logging.INFO
33+
logging.getLogger('fireci').setLevel(level)
3134

3235
plugins.discover()
3336

ci/fireci/fireciplugins/clean.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright 2024 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import click
16+
import logging
17+
18+
from fireci import ci_command
19+
from fireci import ci_utils
20+
from fireci import dir_utils
21+
from typing import Tuple, List, Callable, Union
22+
from termcolor import colored
23+
24+
log = logging.getLogger('fireci.clean')
25+
26+
@click.argument("projects",
27+
nargs=-1,
28+
type=click.Path(),
29+
required=False
30+
)
31+
@click.option('--gradle/--no-gradle', default=False, help="Delete the local .gradle caches.")
32+
@click.option('--build/--no-build', default=True, help="Delete the local build caches.")
33+
@click.option('--transforms/--no-transforms', default=False, help="Delete the system-wide transforms cache.")
34+
@click.option('--build-cache/--no-build-cache', default=False, help="Delete the system-wide build cache.")
35+
36+
@click.option('--deep/--no-deep', default=False, help="Delete all of the system-wide files for gradle.")
37+
@click.option('--cache/--no-cache', default=False, help="Delete all of the system-wide caches for gradle.")
38+
@ci_command(epilog="""
39+
Clean a subset of projects:
40+
41+
\b
42+
$ fireci clean firebase-common
43+
$ fireci clean firebase-common firebase-vertexai
44+
45+
Clean all projects:
46+
47+
$ fireci clean
48+
""")
49+
def clean(projects, gradle, build, transforms, build_cache, deep, cache):
50+
"""
51+
Delete files cached by gradle.
52+
53+
Alternative to the standard `gradlew clean`, which runs outside the scope of gradle,
54+
and provides deeper cache cleaning capabilities.
55+
"""
56+
if not projects:
57+
log.debug("No projects specified, so we're defaulting to all projects.")
58+
projects = ci_utils.get_projects()
59+
60+
cache = cache or deep
61+
gradle = gradle or cache
62+
63+
cleaners = []
64+
65+
if build:
66+
cleaners.append(delete_build)
67+
if gradle:
68+
cleaners.append(delete_gradle)
69+
70+
results = [call_and_sum(projects, cleaner) for cleaner in cleaners]
71+
local_count = tuple(map(sum, zip(*results)))
72+
73+
cleaners = []
74+
75+
if deep:
76+
cleaners.append(delete_deep)
77+
elif cache:
78+
cleaners.append(delete_cache)
79+
else:
80+
if transforms:
81+
cleaners.append(delete_transforms)
82+
if build_cache:
83+
cleaners.append(delete_build_cache)
84+
85+
results = [cleaner() for cleaner in cleaners]
86+
system_count = ci_utils.counts(results)
87+
88+
[deleted, skipped] = tuple(a + b for a, b in zip(local_count, system_count))
89+
90+
log.info(f"""
91+
Clean results:
92+
93+
{colored("Deleted:", None, attrs=["bold"])} {colored(deleted, "red")}
94+
{colored("Already deleted:", None, attrs=["bold"])} {colored(skipped, "grey")}
95+
""")
96+
97+
98+
def call_and_sum(variables: List[str], func: Callable[[str], Union[bool, int]]) -> Tuple[int, int]:
99+
results = list(map(lambda var: func(var), variables))
100+
return ci_utils.counts(results)
101+
102+
def delete_build(dir: str) -> bool:
103+
return dir_utils.rmdir(f"{dir}/build")
104+
105+
def delete_gradle(dir: str) -> bool:
106+
return dir_utils.rmdir(f"{dir}/.gradle")
107+
108+
def delete_transforms() -> int:
109+
return dir_utils.rmglob("~/.gradle/caches/transforms-*")
110+
111+
def delete_build_cache() -> int:
112+
return dir_utils.rmglob("~/.gradle/caches/build-cache-*")
113+
114+
def delete_deep() -> bool:
115+
return dir_utils.rmdir("~/.gradle")
116+
117+
def delete_cache() -> bool:
118+
return dir_utils.rmdir("~/.gradle/caches")

ci/fireci/setup.cfg

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ version = 0.1
44

55
[options]
66
install_requires =
7-
protobuf==3.19
8-
click==8.1.3
9-
google-cloud-storage==2.5.0
10-
mypy==0.991
11-
numpy==1.23.1
12-
pandas==1.5.1
13-
PyGithub==1.55
14-
pystache==0.6.0
15-
requests==2.23.0
16-
seaborn==0.12.1
17-
PyYAML==6.0.0
7+
protobuf==3.20.3
8+
click==8.1.7
9+
google-cloud-storage==2.18.2
10+
mypy==1.6.0
11+
numpy==1.24.4
12+
pandas==1.5.3
13+
PyGithub==1.58.2
14+
pystache==0.6.0
15+
requests==2.31.0
16+
seaborn==0.12.2
17+
PyYAML==6.0.1
18+
termcolor==2.4.0
1819

1920
[options.extras_require]
2021
test =

0 commit comments

Comments
 (0)