xref: /linux-6.15/scripts/checktransupdate.py (revision 63e96ce0)
150c0fa7fSDongliang Mu#!/usr/bin/env python3
250c0fa7fSDongliang Mu# SPDX-License-Identifier: GPL-2.0
350c0fa7fSDongliang Mu
450c0fa7fSDongliang Mu"""
550c0fa7fSDongliang MuThis script helps track the translation status of the documentation
650c0fa7fSDongliang Muin different locales, e.g., zh_CN. More specially, it uses `git log`
750c0fa7fSDongliang Mucommit to find the latest english commit from the translation commit
850c0fa7fSDongliang Mu(order by author date) and the latest english commits from HEAD. If
950c0fa7fSDongliang Mudifferences occur, report the file and commits that need to be updated.
1050c0fa7fSDongliang Mu
1150c0fa7fSDongliang MuThe usage is as follows:
1250c0fa7fSDongliang Mu- ./scripts/checktransupdate.py -l zh_CN
13*63e96ce0SDongliang MuThis will print all the files that need to be updated or translated in the zh_CN locale.
1450c0fa7fSDongliang Mu- ./scripts/checktransupdate.py Documentation/translations/zh_CN/dev-tools/testing-overview.rst
1550c0fa7fSDongliang MuThis will only print the status of the specified file.
1650c0fa7fSDongliang Mu
1750c0fa7fSDongliang MuThe output is something like:
18*63e96ce0SDongliang MuDocumentation/dev-tools/kfence.rst
19*63e96ce0SDongliang MuNo translation in the locale of zh_CN
20*63e96ce0SDongliang Mu
21*63e96ce0SDongliang MuDocumentation/translations/zh_CN/dev-tools/testing-overview.rst
2250c0fa7fSDongliang Mucommit 42fb9cfd5b18 ("Documentation: dev-tools: Add link to RV docs")
23*63e96ce0SDongliang Mu1 commits needs resolving in total
2450c0fa7fSDongliang Mu"""
2550c0fa7fSDongliang Mu
2650c0fa7fSDongliang Muimport os
27*63e96ce0SDongliang Muimport time
28*63e96ce0SDongliang Muimport logging
29*63e96ce0SDongliang Mufrom argparse import ArgumentParser, ArgumentTypeError, BooleanOptionalAction
3050c0fa7fSDongliang Mufrom datetime import datetime
3150c0fa7fSDongliang Mu
3250c0fa7fSDongliang Mu
3350c0fa7fSDongliang Mudef get_origin_path(file_path):
34*63e96ce0SDongliang Mu    """Get the origin path from the translation path"""
3550c0fa7fSDongliang Mu    paths = file_path.split("/")
3650c0fa7fSDongliang Mu    tidx = paths.index("translations")
3750c0fa7fSDongliang Mu    opaths = paths[:tidx]
3850c0fa7fSDongliang Mu    opaths += paths[tidx + 2 :]
3950c0fa7fSDongliang Mu    return "/".join(opaths)
4050c0fa7fSDongliang Mu
4150c0fa7fSDongliang Mu
4250c0fa7fSDongliang Mudef get_latest_commit_from(file_path, commit):
43*63e96ce0SDongliang Mu    """Get the latest commit from the specified commit for the specified file"""
44*63e96ce0SDongliang Mu    command = f"git log --pretty=format:%H%n%aD%n%cD%n%n%B {commit} -1 -- {file_path}"
45*63e96ce0SDongliang Mu    logging.debug(command)
4650c0fa7fSDongliang Mu    pipe = os.popen(command)
4750c0fa7fSDongliang Mu    result = pipe.read()
4850c0fa7fSDongliang Mu    result = result.split("\n")
4950c0fa7fSDongliang Mu    if len(result) <= 1:
5050c0fa7fSDongliang Mu        return None
5150c0fa7fSDongliang Mu
52*63e96ce0SDongliang Mu    logging.debug("Result: %s", result[0])
5350c0fa7fSDongliang Mu
5450c0fa7fSDongliang Mu    return {
5550c0fa7fSDongliang Mu        "hash": result[0],
5650c0fa7fSDongliang Mu        "author_date": datetime.strptime(result[1], "%a, %d %b %Y %H:%M:%S %z"),
5750c0fa7fSDongliang Mu        "commit_date": datetime.strptime(result[2], "%a, %d %b %Y %H:%M:%S %z"),
5850c0fa7fSDongliang Mu        "message": result[4:],
5950c0fa7fSDongliang Mu    }
6050c0fa7fSDongliang Mu
6150c0fa7fSDongliang Mu
6250c0fa7fSDongliang Mudef get_origin_from_trans(origin_path, t_from_head):
63*63e96ce0SDongliang Mu    """Get the latest origin commit from the translation commit"""
6450c0fa7fSDongliang Mu    o_from_t = get_latest_commit_from(origin_path, t_from_head["hash"])
6550c0fa7fSDongliang Mu    while o_from_t is not None and o_from_t["author_date"] > t_from_head["author_date"]:
6650c0fa7fSDongliang Mu        o_from_t = get_latest_commit_from(origin_path, o_from_t["hash"] + "^")
6750c0fa7fSDongliang Mu    if o_from_t is not None:
68*63e96ce0SDongliang Mu        logging.debug("tracked origin commit id: %s", o_from_t["hash"])
6950c0fa7fSDongliang Mu    return o_from_t
7050c0fa7fSDongliang Mu
7150c0fa7fSDongliang Mu
7250c0fa7fSDongliang Mudef get_commits_count_between(opath, commit1, commit2):
73*63e96ce0SDongliang Mu    """Get the commits count between two commits for the specified file"""
74*63e96ce0SDongliang Mu    command = f"git log --pretty=format:%H {commit1}...{commit2} -- {opath}"
75*63e96ce0SDongliang Mu    logging.debug(command)
7650c0fa7fSDongliang Mu    pipe = os.popen(command)
7750c0fa7fSDongliang Mu    result = pipe.read().split("\n")
7850c0fa7fSDongliang Mu    # filter out empty lines
7950c0fa7fSDongliang Mu    result = list(filter(lambda x: x != "", result))
8050c0fa7fSDongliang Mu    return result
8150c0fa7fSDongliang Mu
8250c0fa7fSDongliang Mu
8350c0fa7fSDongliang Mudef pretty_output(commit):
84*63e96ce0SDongliang Mu    """Pretty print the commit message"""
85*63e96ce0SDongliang Mu    command = f"git log --pretty='format:%h (\"%s\")' -1 {commit}"
86*63e96ce0SDongliang Mu    logging.debug(command)
8750c0fa7fSDongliang Mu    pipe = os.popen(command)
8850c0fa7fSDongliang Mu    return pipe.read()
8950c0fa7fSDongliang Mu
9050c0fa7fSDongliang Mu
91*63e96ce0SDongliang Mudef valid_commit(commit):
92*63e96ce0SDongliang Mu    """Check if the commit is valid or not"""
93*63e96ce0SDongliang Mu    msg = pretty_output(commit)
94*63e96ce0SDongliang Mu    return "Merge tag" not in msg
95*63e96ce0SDongliang Mu
9650c0fa7fSDongliang Mudef check_per_file(file_path):
97*63e96ce0SDongliang Mu    """Check the translation status for the specified file"""
9850c0fa7fSDongliang Mu    opath = get_origin_path(file_path)
9950c0fa7fSDongliang Mu
10050c0fa7fSDongliang Mu    if not os.path.isfile(opath):
101*63e96ce0SDongliang Mu        logging.error("Cannot find the origin path for {file_path}")
10250c0fa7fSDongliang Mu        return
10350c0fa7fSDongliang Mu
10450c0fa7fSDongliang Mu    o_from_head = get_latest_commit_from(opath, "HEAD")
10550c0fa7fSDongliang Mu    t_from_head = get_latest_commit_from(file_path, "HEAD")
10650c0fa7fSDongliang Mu
10750c0fa7fSDongliang Mu    if o_from_head is None or t_from_head is None:
108*63e96ce0SDongliang Mu        logging.error("Cannot find the latest commit for %s", file_path)
10950c0fa7fSDongliang Mu        return
11050c0fa7fSDongliang Mu
11150c0fa7fSDongliang Mu    o_from_t = get_origin_from_trans(opath, t_from_head)
11250c0fa7fSDongliang Mu
11350c0fa7fSDongliang Mu    if o_from_t is None:
114*63e96ce0SDongliang Mu        logging.error("Error: Cannot find the latest origin commit for %s", file_path)
11550c0fa7fSDongliang Mu        return
11650c0fa7fSDongliang Mu
11750c0fa7fSDongliang Mu    if o_from_head["hash"] == o_from_t["hash"]:
118*63e96ce0SDongliang Mu        logging.debug("No update needed for %s", file_path)
11950c0fa7fSDongliang Mu    else:
120*63e96ce0SDongliang Mu        logging.info(file_path)
12150c0fa7fSDongliang Mu        commits = get_commits_count_between(
12250c0fa7fSDongliang Mu            opath, o_from_t["hash"], o_from_head["hash"]
12350c0fa7fSDongliang Mu        )
124*63e96ce0SDongliang Mu        count = 0
12550c0fa7fSDongliang Mu        for commit in commits:
126*63e96ce0SDongliang Mu            if valid_commit(commit):
127*63e96ce0SDongliang Mu                logging.info("commit %s", pretty_output(commit))
128*63e96ce0SDongliang Mu                count += 1
129*63e96ce0SDongliang Mu        logging.info("%d commits needs resolving in total\n", count)
130*63e96ce0SDongliang Mu
131*63e96ce0SDongliang Mu
132*63e96ce0SDongliang Mudef valid_locales(locale):
133*63e96ce0SDongliang Mu    """Check if the locale is valid or not"""
134*63e96ce0SDongliang Mu    script_path = os.path.dirname(os.path.abspath(__file__))
135*63e96ce0SDongliang Mu    linux_path = os.path.join(script_path, "..")
136*63e96ce0SDongliang Mu    if not os.path.isdir(f"{linux_path}/Documentation/translations/{locale}"):
137*63e96ce0SDongliang Mu        raise ArgumentTypeError("Invalid locale: {locale}")
138*63e96ce0SDongliang Mu    return locale
139*63e96ce0SDongliang Mu
140*63e96ce0SDongliang Mu
141*63e96ce0SDongliang Mudef list_files_with_excluding_folders(folder, exclude_folders, include_suffix):
142*63e96ce0SDongliang Mu    """List all files with the specified suffix in the folder and its subfolders"""
143*63e96ce0SDongliang Mu    files = []
144*63e96ce0SDongliang Mu    stack = [folder]
145*63e96ce0SDongliang Mu
146*63e96ce0SDongliang Mu    while stack:
147*63e96ce0SDongliang Mu        pwd = stack.pop()
148*63e96ce0SDongliang Mu        # filter out the exclude folders
149*63e96ce0SDongliang Mu        if os.path.basename(pwd) in exclude_folders:
150*63e96ce0SDongliang Mu            continue
151*63e96ce0SDongliang Mu        # list all files and folders
152*63e96ce0SDongliang Mu        for item in os.listdir(pwd):
153*63e96ce0SDongliang Mu            ab_item = os.path.join(pwd, item)
154*63e96ce0SDongliang Mu            if os.path.isdir(ab_item):
155*63e96ce0SDongliang Mu                stack.append(ab_item)
156*63e96ce0SDongliang Mu            else:
157*63e96ce0SDongliang Mu                if ab_item.endswith(include_suffix):
158*63e96ce0SDongliang Mu                    files.append(ab_item)
159*63e96ce0SDongliang Mu
160*63e96ce0SDongliang Mu    return files
161*63e96ce0SDongliang Mu
162*63e96ce0SDongliang Mu
163*63e96ce0SDongliang Muclass DmesgFormatter(logging.Formatter):
164*63e96ce0SDongliang Mu    """Custom dmesg logging formatter"""
165*63e96ce0SDongliang Mu    def format(self, record):
166*63e96ce0SDongliang Mu        timestamp = time.time()
167*63e96ce0SDongliang Mu        formatted_time = f"[{timestamp:>10.6f}]"
168*63e96ce0SDongliang Mu        log_message = f"{formatted_time} {record.getMessage()}"
169*63e96ce0SDongliang Mu        return log_message
170*63e96ce0SDongliang Mu
171*63e96ce0SDongliang Mu
172*63e96ce0SDongliang Mudef config_logging(log_level, log_file="checktransupdate.log"):
173*63e96ce0SDongliang Mu    """configure logging based on the log level"""
174*63e96ce0SDongliang Mu    # set up the root logger
175*63e96ce0SDongliang Mu    logger = logging.getLogger()
176*63e96ce0SDongliang Mu    logger.setLevel(log_level)
177*63e96ce0SDongliang Mu
178*63e96ce0SDongliang Mu    # Create console handler
179*63e96ce0SDongliang Mu    console_handler = logging.StreamHandler()
180*63e96ce0SDongliang Mu    console_handler.setLevel(log_level)
181*63e96ce0SDongliang Mu
182*63e96ce0SDongliang Mu    # Create file handler
183*63e96ce0SDongliang Mu    file_handler = logging.FileHandler(log_file)
184*63e96ce0SDongliang Mu    file_handler.setLevel(log_level)
185*63e96ce0SDongliang Mu
186*63e96ce0SDongliang Mu    # Create formatter and add it to the handlers
187*63e96ce0SDongliang Mu    formatter = DmesgFormatter()
188*63e96ce0SDongliang Mu    console_handler.setFormatter(formatter)
189*63e96ce0SDongliang Mu    file_handler.setFormatter(formatter)
190*63e96ce0SDongliang Mu
191*63e96ce0SDongliang Mu    # Add the handler to the logger
192*63e96ce0SDongliang Mu    logger.addHandler(console_handler)
193*63e96ce0SDongliang Mu    logger.addHandler(file_handler)
19450c0fa7fSDongliang Mu
19550c0fa7fSDongliang Mu
19650c0fa7fSDongliang Mudef main():
197*63e96ce0SDongliang Mu    """Main function of the script"""
19850c0fa7fSDongliang Mu    script_path = os.path.dirname(os.path.abspath(__file__))
19950c0fa7fSDongliang Mu    linux_path = os.path.join(script_path, "..")
20050c0fa7fSDongliang Mu
20150c0fa7fSDongliang Mu    parser = ArgumentParser(description="Check the translation update")
20250c0fa7fSDongliang Mu    parser.add_argument(
20350c0fa7fSDongliang Mu        "-l",
20450c0fa7fSDongliang Mu        "--locale",
205*63e96ce0SDongliang Mu        default="zh_CN",
206*63e96ce0SDongliang Mu        type=valid_locales,
20750c0fa7fSDongliang Mu        help="Locale to check when files are not specified",
20850c0fa7fSDongliang Mu    )
209*63e96ce0SDongliang Mu
21050c0fa7fSDongliang Mu    parser.add_argument(
211*63e96ce0SDongliang Mu        "--print-missing-translations",
21250c0fa7fSDongliang Mu        action=BooleanOptionalAction,
21350c0fa7fSDongliang Mu        default=True,
214*63e96ce0SDongliang Mu        help="Print files that do not have translations",
21550c0fa7fSDongliang Mu    )
21650c0fa7fSDongliang Mu
21750c0fa7fSDongliang Mu    parser.add_argument(
218*63e96ce0SDongliang Mu        '--log',
219*63e96ce0SDongliang Mu        default='INFO',
220*63e96ce0SDongliang Mu        choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
221*63e96ce0SDongliang Mu        help='Set the logging level')
22250c0fa7fSDongliang Mu
22350c0fa7fSDongliang Mu    parser.add_argument(
224*63e96ce0SDongliang Mu        '--logfile',
225*63e96ce0SDongliang Mu        default='checktransupdate.log',
226*63e96ce0SDongliang Mu        help='Set the logging file (default: checktransupdate.log)')
22750c0fa7fSDongliang Mu
22850c0fa7fSDongliang Mu    parser.add_argument(
22950c0fa7fSDongliang Mu        "files", nargs="*", help="Files to check, if not specified, check all files"
23050c0fa7fSDongliang Mu    )
23150c0fa7fSDongliang Mu    args = parser.parse_args()
23250c0fa7fSDongliang Mu
233*63e96ce0SDongliang Mu    # Configure logging based on the --log argument
234*63e96ce0SDongliang Mu    log_level = getattr(logging, args.log.upper(), logging.INFO)
235*63e96ce0SDongliang Mu    config_logging(log_level)
23650c0fa7fSDongliang Mu
237*63e96ce0SDongliang Mu    # Get files related to linux path
23850c0fa7fSDongliang Mu    files = args.files
23950c0fa7fSDongliang Mu    if len(files) == 0:
240*63e96ce0SDongliang Mu        offical_files = list_files_with_excluding_folders(
241*63e96ce0SDongliang Mu            os.path.join(linux_path, "Documentation"), ["translations", "output"], "rst"
24250c0fa7fSDongliang Mu        )
24350c0fa7fSDongliang Mu
244*63e96ce0SDongliang Mu        for file in offical_files:
245*63e96ce0SDongliang Mu            # split the path into parts
246*63e96ce0SDongliang Mu            path_parts = file.split(os.sep)
247*63e96ce0SDongliang Mu            # find the index of the "Documentation" directory
248*63e96ce0SDongliang Mu            kindex = path_parts.index("Documentation")
249*63e96ce0SDongliang Mu            # insert the translations and locale after the Documentation directory
250*63e96ce0SDongliang Mu            new_path_parts = path_parts[:kindex + 1] + ["translations", args.locale] \
251*63e96ce0SDongliang Mu                           + path_parts[kindex + 1 :]
252*63e96ce0SDongliang Mu            # join the path parts back together
253*63e96ce0SDongliang Mu            new_file = os.sep.join(new_path_parts)
254*63e96ce0SDongliang Mu            if os.path.isfile(new_file):
255*63e96ce0SDongliang Mu                files.append(new_file)
256*63e96ce0SDongliang Mu            else:
257*63e96ce0SDongliang Mu                if args.print_missing_translations:
258*63e96ce0SDongliang Mu                    logging.info(os.path.relpath(os.path.abspath(file), linux_path))
259*63e96ce0SDongliang Mu                    logging.info("No translation in the locale of %s\n", args.locale)
260*63e96ce0SDongliang Mu
26150c0fa7fSDongliang Mu    files = list(map(lambda x: os.path.relpath(os.path.abspath(x), linux_path), files))
26250c0fa7fSDongliang Mu
26350c0fa7fSDongliang Mu    # cd to linux root directory
26450c0fa7fSDongliang Mu    os.chdir(linux_path)
26550c0fa7fSDongliang Mu
26650c0fa7fSDongliang Mu    for file in files:
26750c0fa7fSDongliang Mu        check_per_file(file)
26850c0fa7fSDongliang Mu
26950c0fa7fSDongliang Mu
27050c0fa7fSDongliang Muif __name__ == "__main__":
27150c0fa7fSDongliang Mu    main()
272