xref: /aosp_15_r20/external/google-cloud-java/owl-bot-postprocessor/synthtool/languages/java.py (revision 55e87721aa1bc457b326496a7ca40f3ea1a63287)
1# Copyright 2018 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#     https://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
15import glob
16import os
17import xml.etree.ElementTree as ET
18import re
19import requests
20import yaml
21import synthtool as s
22import synthtool.gcp as gcp
23from synthtool import cache, shell
24from synthtool.gcp import common, partials, pregenerated, samples, snippets
25from synthtool.log import logger
26from pathlib import Path
27from typing import Any, Optional, Dict, Iterable, List
28
29JAR_DOWNLOAD_URL = "https://github.com/google/google-java-format/releases/download/google-java-format-{version}/google-java-format-{version}-all-deps.jar"
30DEFAULT_FORMAT_VERSION = "1.7"
31GOOD_LICENSE = """/*
32 * Copyright 2020 Google LLC
33 *
34 * Licensed under the Apache License, Version 2.0 (the "License");
35 * you may not use this file except in compliance with the License.
36 * You may obtain a copy of the License at
37 *
38 *     https://www.apache.org/licenses/LICENSE-2.0
39 *
40 * Unless required by applicable law or agreed to in writing, software
41 * distributed under the License is distributed on an "AS IS" BASIS,
42 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
43 * See the License for the specific language governing permissions and
44 * limitations under the License.
45 */
46"""
47PROTOBUF_HEADER = "// Generated by the protocol buffer compiler.  DO NOT EDIT!"
48BAD_LICENSE = """/\\*
49 \\* Copyright \\d{4} Google LLC
50 \\*
51 \\* Licensed under the Apache License, Version 2.0 \\(the "License"\\); you may not use this file except
52 \\* in compliance with the License. You may obtain a copy of the License at
53 \\*
54 \\* http://www.apache.org/licenses/LICENSE-2.0
55 \\*
56 \\* Unless required by applicable law or agreed to in writing, software distributed under the License
57 \\* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
58 \\* or implied. See the License for the specific language governing permissions and limitations under
59 \\* the License.
60 \\*/
61"""
62DEFAULT_MIN_SUPPORTED_JAVA_VERSION = 8
63
64
65def format_code(
66    path: str, version: str = DEFAULT_FORMAT_VERSION, times: int = 2
67) -> None:
68    """
69    Runs the google-java-format jar against all .java files found within the
70    provided path.
71    """
72    jar_name = f"google-java-format-{version}.jar"
73    jar = cache.get_cache_dir() / jar_name
74    if not jar.exists():
75        _download_formatter(version, jar)
76
77    # Find all .java files in path and run the formatter on them
78    files = list(glob.iglob(os.path.join(path, "**/*.java"), recursive=True))
79
80    # Run the formatter as a jar file
81    logger.info("Running java formatter on {} files".format(len(files)))
82    for _ in range(times):
83        shell.run(["java", "-jar", str(jar), "--replace"] + files)
84
85
86def _download_formatter(version: str, dest: Path) -> None:
87    logger.info("Downloading java formatter")
88    url = JAR_DOWNLOAD_URL.format(version=version)
89    response = requests.get(url)
90    response.raise_for_status()
91    with open(dest, "wb") as fh:
92        fh.write(response.content)
93
94
95HEADER_REGEX = re.compile("\\* Copyright \\d{4} Google LLC")
96
97
98def _file_has_header(path: Path) -> bool:
99    """Return true if the file already contains a license header."""
100    with open(path, "rt") as fp:
101        for line in fp:
102            if HEADER_REGEX.search(line):
103                return True
104    return False
105
106
107def _filter_no_header(paths: Iterable[Path]) -> Iterable[Path]:
108    """Return a subset of files that do not already have a header."""
109    for path in paths:
110        anchor = Path(path.anchor)
111        remainder = str(path.relative_to(path.anchor))
112        for file in anchor.glob(remainder):
113            if not _file_has_header(file):
114                yield file
115
116
117def fix_proto_headers(proto_root: Path) -> None:
118    """Helper to ensure that generated proto classes have appropriate license headers.
119
120    If the file does not already contain a license header, inject one at the top of the file.
121    Some resource name classes may contain malformed license headers. In those cases, replace
122    those with our standard license header.
123    """
124    s.replace(
125        _filter_no_header([proto_root / "src/**/*.java"]),
126        PROTOBUF_HEADER,
127        f"{GOOD_LICENSE}{PROTOBUF_HEADER}",
128    )
129    # https://github.com/googleapis/gapic-generator/issues/3074
130    s.replace(
131        [proto_root / "src/**/*Name.java", proto_root / "src/**/*Names.java"],
132        BAD_LICENSE,
133        GOOD_LICENSE,
134    )
135
136
137def fix_grpc_headers(grpc_root: Path, package_name: str = "unused") -> None:
138    """Helper to ensure that generated grpc stub classes have appropriate license headers.
139
140    If the file does not already contain a license header, inject one at the top of the file.
141    """
142    s.replace(
143        _filter_no_header([grpc_root / "src/**/*.java"]),
144        "^package (.*);",
145        f"{GOOD_LICENSE}package \\1;",
146    )
147
148
149def latest_maven_version(group_id: str, artifact_id: str) -> Optional[str]:
150    """Helper function to find the latest released version of a Maven artifact.
151
152    Fetches metadata from Maven Central and parses out the latest released
153    version.
154
155    Args:
156        group_id (str): The groupId of the Maven artifact
157        artifact_id (str): The artifactId of the Maven artifact
158
159    Returns:
160        The latest version of the artifact as a string or None
161    """
162    group_path = "/".join(group_id.split("."))
163    url = (
164        f"https://repo1.maven.org/maven2/{group_path}/{artifact_id}/maven-metadata.xml"
165    )
166    response = requests.get(url)
167    if response.status_code >= 400:
168        return "0.0.0"
169
170    return version_from_maven_metadata(response.text)
171
172
173def version_from_maven_metadata(metadata: str) -> Optional[str]:
174    """Helper function to parse the latest released version from the Maven
175    metadata XML file.
176
177    Args:
178        metadata (str): The XML contents of the Maven metadata file
179
180    Returns:
181        The latest version of the artifact as a string or None
182    """
183    root = ET.fromstring(metadata)
184    latest = root.find("./versioning/latest")
185    if latest is not None:
186        return latest.text
187
188    return None
189
190
191def _common_generation(
192    service: str,
193    version: str,
194    library: Path,
195    package_pattern: str,
196    suffix: str = "",
197    destination_name: str = None,
198    cloud_api: bool = True,
199    diregapic: bool = False,
200    preserve_gapic: bool = False,
201):
202    """Helper function to execution the common generation cleanup actions.
203
204    Fixes headers for protobuf classes and generated gRPC stub services. Copies
205    code and samples to their final destinations by convention. Runs the code
206    formatter on the generated code.
207
208    Args:
209        service (str): Name of the service.
210        version (str): Service API version.
211        library (Path): Path to the temp directory with the generated library.
212        package_pattern (str): Package name template for fixing file headers.
213        suffix (str, optional): Suffix that the generated library folder. The
214            artman output differs from bazel's output directory. Defaults to "".
215        destination_name (str, optional): Override the service name for the
216            destination of the output code. Defaults to the service name.
217        preserve_gapic (bool, optional): Whether to preserve the gapic directory
218            prefix. Default False.
219    """
220
221    if destination_name is None:
222        destination_name = service
223
224    cloud_prefix = "cloud-" if cloud_api else ""
225    package_name = package_pattern.format(service=service, version=version)
226    fix_proto_headers(
227        library / f"proto-google-{cloud_prefix}{service}-{version}{suffix}"
228    )
229    fix_grpc_headers(
230        library / f"grpc-google-{cloud_prefix}{service}-{version}{suffix}", package_name
231    )
232
233    if preserve_gapic:
234        s.copy(
235            [library / f"gapic-google-{cloud_prefix}{service}-{version}{suffix}/src"],
236            f"gapic-google-{cloud_prefix}{destination_name}-{version}/src",
237            required=True,
238        )
239    else:
240        s.copy(
241            [library / f"gapic-google-{cloud_prefix}{service}-{version}{suffix}/src"],
242            f"google-{cloud_prefix}{destination_name}/src",
243            required=True,
244        )
245
246    s.copy(
247        [library / f"grpc-google-{cloud_prefix}{service}-{version}{suffix}/src"],
248        f"grpc-google-{cloud_prefix}{destination_name}-{version}/src",
249        # For REST-only clients, like java-compute, gRPC artifact does not exist
250        required=(not diregapic),
251    )
252    s.copy(
253        [library / f"proto-google-{cloud_prefix}{service}-{version}{suffix}/src"],
254        f"proto-google-{cloud_prefix}{destination_name}-{version}/src",
255        required=True,
256    )
257
258    if preserve_gapic:
259        format_code(f"gapic-google-{cloud_prefix}{destination_name}-{version}/src")
260    else:
261        format_code(f"google-{cloud_prefix}{destination_name}/src")
262    format_code(f"grpc-google-{cloud_prefix}{destination_name}-{version}/src")
263    format_code(f"proto-google-{cloud_prefix}{destination_name}-{version}/src")
264
265
266def gapic_library(
267    service: str,
268    version: str,
269    config_pattern: str = "/google/cloud/{service}/artman_{service}_{version}.yaml",
270    package_pattern: str = "com.google.cloud.{service}.{version}",
271    gapic: gcp.GAPICGenerator = None,
272    destination_name: str = None,
273    diregapic: bool = False,
274    preserve_gapic: bool = False,
275    **kwargs,
276) -> Path:
277    """Generate a Java library using the gapic-generator via artman via Docker.
278
279    Generates code into a temp directory, fixes missing header fields, and
280    copies into the expected locations.
281
282    Args:
283        service (str): Name of the service.
284        version (str): Service API version.
285        config_pattern (str, optional): Path template to artman config YAML
286            file. Defaults to "/google/cloud/{service}/artman_{service}_{version}.yaml"
287        package_pattern (str, optional): Package name template for fixing file
288            headers. Defaults to "com.google.cloud.{service}.{version}".
289        gapic (GAPICGenerator, optional): Generator instance.
290        destination_name (str, optional): Override the service name for the
291            destination of the output code. Defaults to the service name.
292        preserve_gapic (bool, optional): Whether to preserve the gapic directory
293            prefix. Default False.
294        **kwargs: Additional options for gapic.java_library()
295
296    Returns:
297        The path to the temp directory containing the generated client.
298    """
299    if gapic is None:
300        gapic = gcp.GAPICGenerator()
301
302    library = gapic.java_library(
303        service=service,
304        version=version,
305        config_path=config_pattern.format(service=service, version=version),
306        artman_output_name="",
307        include_samples=True,
308        diregapic=diregapic,
309        **kwargs,
310    )
311
312    _common_generation(
313        service=service,
314        version=version,
315        library=library,
316        package_pattern=package_pattern,
317        destination_name=destination_name,
318        diregapic=diregapic,
319        preserve_gapic=preserve_gapic,
320    )
321
322    return library
323
324
325def bazel_library(
326    service: str,
327    version: str,
328    package_pattern: str = "com.google.cloud.{service}.{version}",
329    gapic: gcp.GAPICBazel = None,
330    destination_name: str = None,
331    cloud_api: bool = True,
332    diregapic: bool = False,
333    preserve_gapic: bool = False,
334    **kwargs,
335) -> Path:
336    """Generate a Java library using the gapic-generator via bazel.
337
338    Generates code into a temp directory, fixes missing header fields, and
339    copies into the expected locations.
340
341    Args:
342        service (str): Name of the service.
343        version (str): Service API version.
344        package_pattern (str, optional): Package name template for fixing file
345            headers. Defaults to "com.google.cloud.{service}.{version}".
346        gapic (GAPICBazel, optional): Generator instance.
347        destination_name (str, optional): Override the service name for the
348            destination of the output code. Defaults to the service name.
349        preserve_gapic (bool, optional): Whether to preserve the gapic directory
350            prefix. Default False.
351        **kwargs: Additional options for gapic.java_library()
352
353    Returns:
354        The path to the temp directory containing the generated client.
355    """
356    if gapic is None:
357        gapic = gcp.GAPICBazel()
358
359    library = gapic.java_library(
360        service=service, version=version, diregapic=diregapic, **kwargs
361    )
362
363    _common_generation(
364        service=service,
365        version=version,
366        library=library / f"google-cloud-{service}-{version}-java",
367        package_pattern=package_pattern,
368        suffix="-java",
369        destination_name=destination_name,
370        cloud_api=cloud_api,
371        diregapic=diregapic,
372        preserve_gapic=preserve_gapic,
373    )
374
375    return library
376
377
378def pregenerated_library(
379    path: str,
380    service: str,
381    version: str,
382    destination_name: str = None,
383    cloud_api: bool = True,
384) -> Path:
385    """Generate a Java library using the gapic-generator via bazel.
386
387    Generates code into a temp directory, fixes missing header fields, and
388    copies into the expected locations.
389
390    Args:
391        path (str): Path in googleapis-gen to un-versioned generated code.
392        service (str): Name of the service.
393        version (str): Service API version.
394        destination_name (str, optional): Override the service name for the
395            destination of the output code. Defaults to the service name.
396        cloud_api (bool, optional): Whether or not this is a cloud API (for naming)
397
398    Returns:
399        The path to the temp directory containing the generated client.
400    """
401    generator = pregenerated.Pregenerated()
402    library = generator.generate(path)
403
404    cloud_prefix = "cloud-" if cloud_api else ""
405    _common_generation(
406        service=service,
407        version=version,
408        library=library / f"google-{cloud_prefix}{service}-{version}-java",
409        package_pattern="unused",
410        suffix="-java",
411        destination_name=destination_name,
412        cloud_api=cloud_api,
413    )
414
415    return library
416
417
418def _merge_release_please(destination_text: str):
419    config = yaml.safe_load(destination_text)
420    if "handleGHRelease" in config:
421        return destination_text
422
423    config["handleGHRelease"] = True
424
425    if "branches" in config:
426        for branch in config["branches"]:
427            branch["handleGHRelease"] = True
428    return yaml.dump(config)
429
430
431def _merge_common_templates(
432    source_text: str, destination_text: str, file_path: Path
433) -> str:
434    # keep any existing pom.xml
435    if file_path.match("pom.xml") or file_path.match("sync-repo-settings.yaml"):
436        logger.debug(f"existing pom file found ({file_path}) - keeping the existing")
437        return destination_text
438
439    if file_path.match("release-please.yml"):
440        return _merge_release_please(destination_text)
441
442    # by default return the newly generated content
443    return source_text
444
445
446def _common_template_metadata() -> Dict[str, Any]:
447    metadata = {}  # type: Dict[str, Any]
448    repo_metadata = common._load_repo_metadata()
449    if repo_metadata:
450        metadata["repo"] = repo_metadata
451        group_id, artifact_id = repo_metadata["distribution_name"].split(":")
452
453        metadata["latest_version"] = latest_maven_version(
454            group_id=group_id, artifact_id=artifact_id
455        )
456
457    metadata["latest_bom_version"] = latest_maven_version(
458        group_id="com.google.cloud",
459        artifact_id="libraries-bom",
460    )
461
462    metadata["samples"] = samples.all_samples(["samples/**/src/main/java/**/*.java"])
463    metadata["snippets"] = snippets.all_snippets(
464        ["samples/**/src/main/java/**/*.java", "samples/**/pom.xml"]
465    )
466    if repo_metadata and "min_java_version" in repo_metadata:
467        metadata["min_java_version"] = repo_metadata["min_java_version"]
468    else:
469        metadata["min_java_version"] = DEFAULT_MIN_SUPPORTED_JAVA_VERSION
470
471    return metadata
472
473
474def common_templates(
475    excludes: List[str] = [], template_path: Optional[Path] = None, **kwargs
476) -> None:
477    """Generate common templates for a Java Library
478
479    Fetches information about the repository from the .repo-metadata.json file,
480    information about the latest artifact versions and copies the files into
481    their expected location.
482
483    Args:
484        excludes (List[str], optional): List of template paths to ignore
485        **kwargs: Additional options for CommonTemplates.java_library()
486    """
487    metadata = _common_template_metadata()
488    kwargs["metadata"] = metadata
489
490    # Generate flat to tell this repository is a split repo that have migrated
491    # to monorepo. The owlbot.py in the monorepo sets monorepo=True.
492    monorepo = kwargs.get("monorepo", False)
493    split_repo = not monorepo
494    repo_metadata = metadata["repo"]
495    repo_short = repo_metadata["repo_short"]
496    # Special libraries that are not GAPIC_AUTO but in the monorepo
497    special_libs_in_monorepo = [
498        "java-translate",
499        "java-dns",
500        "java-notification",
501        "java-resourcemanager",
502    ]
503    kwargs["migrated_split_repo"] = split_repo and (
504        repo_metadata["library_type"] == "GAPIC_AUTO"
505        or (repo_short and repo_short in special_libs_in_monorepo)
506    )
507    logger.info(
508        "monorepo: {}, split_repo: {}, library_type: {},"
509        " repo_short: {}, migrated_split_repo: {}".format(
510            monorepo,
511            split_repo,
512            repo_metadata["library_type"],
513            repo_short,
514            kwargs["migrated_split_repo"],
515        )
516    )
517
518    templates = gcp.CommonTemplates(template_path=template_path).java_library(**kwargs)
519
520    # skip README generation on Kokoro (autosynth)
521    if os.environ.get("KOKORO_ROOT") is not None:
522        # README.md is now synthesized separately. This prevents synthtool from deleting the
523        # README as it's no longer generated here.
524        excludes.append("README.md")
525
526    s.copy([templates], excludes=excludes, merge=_merge_common_templates)
527
528
529def custom_templates(files: List[str], **kwargs) -> None:
530    """Generate custom template files
531
532    Fetches information about the repository from the .repo-metadata.json file,
533    information about the latest artifact versions and copies the files into
534    their expected location.
535
536    Args:
537        files (List[str], optional): List of template paths to include
538        **kwargs: Additional options for CommonTemplates.render()
539    """
540    kwargs["metadata"] = _common_template_metadata()
541    kwargs["metadata"]["partials"] = partials.load_partials()
542    for file in files:
543        template = gcp.CommonTemplates().render(file, **kwargs)
544        s.copy([template])
545
546
547def remove_method(filename: str, signature: str):
548    """Helper to remove an entire method.
549
550    Goes line-by-line to detect the start of the block. Determines
551    the end of the block by a closing brace at the same indentation
552    level. This requires the file to be correctly formatted.
553
554    Example: consider the following class:
555
556        class Example {
557            public void main(String[] args) {
558                System.out.println("Hello World");
559            }
560
561            public String foo() {
562                return "bar";
563            }
564        }
565
566    To remove the `main` method above, use:
567
568        remove_method('path/to/file', 'public void main(String[] args)')
569
570    Args:
571        filename (str): Path to source file
572        signature (str): Full signature of the method to remove. Example:
573            `public void main(String[] args)`.
574    """
575    lines = []
576    leading_regex = None
577    with open(filename, "r") as fp:
578        line = fp.readline()
579        while line:
580            # for each line, try to find the matching
581            regex = re.compile("(\\s*)" + re.escape(signature) + ".*")
582            match = regex.match(line)
583            if match:
584                leading_regex = re.compile(match.group(1) + "}")
585                line = fp.readline()
586                continue
587
588            # not in a ignore block - preserve the line
589            if not leading_regex:
590                lines.append(line)
591                line = fp.readline()
592                continue
593
594            # detect the closing tag based on the leading spaces
595            match = leading_regex.match(line)
596            if match:
597                # block is closed, resume capturing content
598                leading_regex = None
599
600            line = fp.readline()
601
602    with open(filename, "w") as fp:
603        for line in lines:
604            # print(line)
605            fp.write(line)
606
607
608def copy_and_rename_method(filename: str, signature: str, before: str, after: str):
609    """Helper to make a copy an entire method and rename it.
610
611    Goes line-by-line to detect the start of the block. Determines
612    the end of the block by a closing brace at the same indentation
613    level. This requires the file to be correctly formatted.
614    The method is copied over and renamed in the method signature.
615    The calls to both methods are separate and unaffected.
616
617    Example: consider the following class:
618
619        class Example {
620            public void main(String[] args) {
621                System.out.println("Hello World");
622            }
623
624            public String foo() {
625                return "bar";
626            }
627        }
628
629    To copy and rename the `main` method above, use:
630
631    copy_and_rename_method('path/to/file', 'public void main(String[] args)',
632        'main', 'foo1')
633
634    Args:
635        filename (str): Path to source file
636        signature (str): Full signature of the method to remove. Example:
637            `public void main(String[] args)`.
638        before (str): name of the method to be copied
639        after (str): new name of the copied method
640    """
641    lines = []
642    method = []
643    leading_regex = None
644    with open(filename, "r") as fp:
645        line = fp.readline()
646        while line:
647            # for each line, try to find the matching
648            regex = re.compile("(\\s*)" + re.escape(signature) + ".*")
649            match = regex.match(line)
650            if match:
651                leading_regex = re.compile(match.group(1) + "}")
652                lines.append(line)
653                method.append(line.replace(before, after))
654                line = fp.readline()
655                continue
656
657            lines.append(line)
658            # not in a ignore block - preserve the line
659            if leading_regex:
660                method.append(line)
661            else:
662                line = fp.readline()
663                continue
664
665            # detect the closing tag based on the leading spaces
666            match = leading_regex.match(line)
667            if match:
668                # block is closed, resume capturing content
669                leading_regex = None
670                lines.append("\n")
671                lines.extend(method)
672
673            line = fp.readline()
674
675    with open(filename, "w") as fp:
676        for line in lines:
677            # print(line)
678            fp.write(line)
679
680
681def add_javadoc(filename: str, signature: str, javadoc_type: str, content: List[str]):
682    """Helper to add a javadoc annoatation to a method.
683
684        Goes line-by-line to detect the start of the block.
685        Then finds the existing method comment (if it exists). If the
686        comment already exists, it will append the javadoc annotation
687        to the javadoc block. Otherwise, it will create a new javadoc
688        comment block.
689
690        Example: consider the following class:
691
692            class Example {
693                public void main(String[] args) {
694                    System.out.println("Hello World");
695                }
696
697                public String foo() {
698                    return "bar";
699                }
700            }
701
702        To add a javadoc annotation the `main` method above, use:
703
704        add_javadoc('path/to/file', 'public void main(String[] args)',
705            'deprecated', 'Please use foo instead.')
706
707    Args:
708        filename (str): Path to source file
709        signature (str): Full signature of the method to remove. Example:
710            `public void main(String[] args)`.
711        javadoc_type (str): The type of javadoc annotation. Example: `deprecated`.
712        content (List[str]): The javadoc lines
713    """
714    lines: List[str] = []
715    annotations: List[str] = []
716    with open(filename, "r") as fp:
717        line = fp.readline()
718        while line:
719            # for each line, try to find the matching
720            regex = re.compile("(\\s*)" + re.escape(signature) + ".*")
721            match = regex.match(line)
722            if match:
723                leading_spaces = len(line) - len(line.lstrip())
724                indent = leading_spaces * " "
725                last_line = lines.pop()
726                while last_line.lstrip() and last_line.lstrip()[0] == "@":
727                    annotations.append(last_line)
728                    last_line = lines.pop()
729                if last_line.strip() == "*/":
730                    first = True
731                    for content_line in content:
732                        if first:
733                            lines.append(
734                                indent
735                                + " * @"
736                                + javadoc_type
737                                + " "
738                                + content_line
739                                + "\n"
740                            )
741                            first = False
742                        else:
743                            lines.append(indent + " *   " + content_line + "\n")
744                    lines.append(last_line)
745                else:
746                    lines.append(last_line)
747                    lines.append(indent + "/**\n")
748                    first = True
749                    for content_line in content:
750                        if first:
751                            lines.append(
752                                indent
753                                + " * @"
754                                + javadoc_type
755                                + " "
756                                + content_line
757                                + "\n"
758                            )
759                            first = False
760                        else:
761                            lines.append(indent + " *   " + content_line + "\n")
762                    lines.append(indent + " */\n")
763                lines.extend(annotations[::-1])
764            lines.append(line)
765            line = fp.readline()
766
767    with open(filename, "w") as fp:
768        for line in lines:
769            # print(line)
770            fp.write(line)
771
772
773def annotate_method(filename: str, signature: str, annotation: str):
774    """Helper to add an annotation to a method.
775
776        Goes line-by-line to detect the start of the block.
777        Then adds the annotation above the found method signature.
778
779        Example: consider the following class:
780
781            class Example {
782                public void main(String[] args) {
783                    System.out.println("Hello World");
784                }
785
786                public String foo() {
787                    return "bar";
788                }
789            }
790
791        To add an annotation the `main` method above, use:
792
793        annotate_method('path/to/file', 'public void main(String[] args)',
794            '@Generated()')
795
796    Args:
797        filename (str): Path to source file
798        signature (str): Full signature of the method to remove. Example:
799            `public void main(String[] args)`.
800        annotation (str): Full annotation. Example: `@Deprecated`
801    """
802    lines: List[str] = []
803    with open(filename, "r") as fp:
804        line = fp.readline()
805        while line:
806            # for each line, try to find the matching
807            regex = re.compile("(\\s*)" + re.escape(signature) + ".*")
808            match = regex.match(line)
809            if match:
810                leading_spaces = len(line) - len(line.lstrip())
811                indent = leading_spaces * " "
812                lines.append(indent + annotation + "\n")
813            lines.append(line)
814            line = fp.readline()
815
816    with open(filename, "w") as fp:
817        for line in lines:
818            # print(line)
819            fp.write(line)
820
821
822def deprecate_method(filename: str, signature: str, alternative: str):
823    """Helper to deprecate a method.
824
825        Goes line-by-line to detect the start of the block.
826        Then adds the deprecation comment before the method signature.
827        The @Deprecation annotation is also added.
828
829        Example: consider the following class:
830
831            class Example {
832                public void main(String[] args) {
833                    System.out.println("Hello World");
834                }
835
836                public String foo() {
837                    return "bar";
838                }
839            }
840
841        To deprecate the `main` method above, use:
842
843        deprecate_method('path/to/file', 'public void main(String[] args)',
844            DEPRECATION_WARNING.format(new_method="foo"))
845
846    Args:
847        filename (str): Path to source file
848        signature (str): Full signature of the method to remove. Example:
849            `public void main(String[] args)`.
850        alternative: DEPRECATION WARNING: multiline javadoc comment with user
851            specified leading open/close comment tags
852    """
853    add_javadoc(filename, signature, "deprecated", alternative.splitlines())
854    annotate_method(filename, signature, "@Deprecated")
855