# code quality A series of 10 article(s), in reading order. # Markdown linter - solve formatting issue in md files > Automatically solve Markdown formatting issues with the markdownlint tool and Docker. Learn how to check, configure, and use the --fix flag for consistent, clean .md files. --- ![Markdown linter - solve formatting issue in md files](/img/v2/clean_code.webp) **TL;DR** This article shows how to lint and auto-fix Markdown files using the `peterdavehello/markdownlint` Docker image: scanning a project, ignoring folders like `vendor` or `node_modules` via `.markdownlint_ignore`, disabling specific rules via `.markdownlint.json`, and applying automatic fixes with the `--fix` flag, wired up as a `make lint` target. You are writing Markdown `.md` files (and you're so right) and you just wish to check (and autofix) some issues like having multiple blank lines, mixing bullet types (`-` and `*` in the same document), using a `#` title followed by `###` (i.e. you forget the level `##`) and many more. There is a tool for this: Markdown lint and, a Docker image `peterdavehello/markdownlint`. Let's learn how to use it. *Like every linter, it's most useful when you don't have to think about it: [Git - pre-commit-hooks](/blog/git-precommit) lists a `markdownlint` hook that runs on every commit.* By running `docker run --rm -v .:/md peterdavehello/markdownlint markdownlint .` you'll scan your current folder (recursively), search for any Markdown files and get the list of errors if any. And the list can be huge if you're using dependencies like, for PHP, the `vendor` folder or `node_modules` for npm/yarn. Just ignore them. ## Ignore some files/folders Create a file called `.markdownlint_ignore` in the root directory of your project with this content: **.markdownlint\_ignore** ``` node_modules/**/*.markdown node_modules/**/*.md vendor/**/*.markdown vendor/**/*.md ``` This time, please run `docker run --rm -v .:/md peterdavehello/markdownlint markdownlint --ignore-path .markdownlint_ignore .`. The list will probably be better and only contains your files. ## Ignore some errors You can ignore some detected errors like the use of inline HTML by creating a file called `.markdownlint.json`. You can find an example on . See for more information. To use your configuration file, now, please run `docker run --rm -v .:/md peterdavehello/markdownlint markdownlint --config .markdownlint.json --ignore-path .markdownlint_ignore .`. ## Some errors can be fixed automagically Some errors can be fixed automagically like multiple empty lines or by trimming extra spaces at the end of lines. The flag here is `--fix` but, pay attention, since fixes will update files you should also provide the `--user $(id -u):$(id -g)` flag so files will be updated but will still be owned by you. ## Put all together The final instruction becomes: `docker run --rm --user $(id -u):$(id -g) -v .:/md peterdavehello/markdownlint markdownlint --fix --config .markdownlint.json --ignore-path .markdownlint_ignore .` and that's the one I'm running every time when I publish this blog. ## Make it easy You don't need to remember the entire command of course. Using a [Makefile](/blog/tags/makefile) in your project, you can then create a new `target` where you put the command. For this blog f.i., I'm running `make lint` in my deploy process. You can see this [here](https://github.com/cavo789/blog/blob/main/makefile#L42-L45). --- # Rector 1.0.0 πŸŽ‰πŸŽ‰πŸŽ‰, my friend, my coach > Meet PHP Rector, the free, powerful tool for automatic PHP codebase upgrades and refactoring. Instantly improve code quality and become a better PHP developer. --- ![Rector 1.0.0 πŸŽ‰πŸŽ‰πŸŽ‰, my friend, my coach](/img/v2/rector.webp) **TL;DR** This article introduces Rector, a free tool that scans PHP code and suggests (or automatically applies) modernization and refactoring improvements. Using a simple `sayHello` function as an example, it shows Rector simplifying an `if/else` into a ternary expression, removing a useless variable, and adding proper type hints and a return type β€” all via `vendor/bin/rector process --dry-run --config rector.php`. If there was only one, it would be [Rector](https://github.com/rectorphp/rector). **Rector is extremely powerful and brilliant and, you know what, it's free!** Rector is a tremendous application to scan and automatically upgrade your codebase to a given version of PHP and this means, too, to inspect how you're coding. Rector will look at every line of code, every loop, every conditional test, every function, every class in a PHP source code and suggest improvements. In this role, it will not only detect improvements to my code (like a static analysis tool) but, and this is the biggest advantage for me, it will show me how I can become a better developer. I REALLY LOVE RECTOR πŸ’• Like a lot of people, I learned PHP on my own and that was at the time of PHP 5. Like a lot of people, I certainly picked up some bad habits and because it's impossible to keep up with every evolution of the language, my skills don't increase at the same time as the language. So, perhaps, I'm still using *old-fashioned* syntax like doing a simple `if ... then ... else` when in fact it's not useful. Maybe I'm going to use a variable to store a value when it's absolutely useless. Let's take a look at a very simple example (`sayHello.php`): **sayHello.php** ```php **I'm an excellent developer. Thank you and goodbye.:** > > Well, in fact, **I was an excellent developer** but I stopped being one ten years ago. What's wrong with my code? * `$firstname` is a string no? So why not write `string $firstname`?, * since PHP 7.x, there is a thing called `ternary operator`; you didn't know that?, * Using the `ternary operator`; the `$text` variable becomes useless and * the function is returning a string (the `$text` variable) but the prototype of the function didn't mention this. So why not write `function sayHello($firstname = ''): string` ## Here is what Rector says about it Before you install anything, here is the answer Rector gives on that exact file: ![Rector is simplifying our sayHello function](./images/rector_say_hello.webp) A colored diff β€” the code as it is, the code as it could be β€” and, at the bottom of the screen, an `Applied rules:` section. ## Why it works * **Rector reasons in named rules, not in patterns.** Every change comes with the name of the rule that produced it, and that name is a link to a documented explanation. * **It parses your code, it doesn't grep it.** Rector builds the syntax tree of the file, which is why it can tell that a variable is only there to be returned, or that a return value can only ever be a string. * **Nothing moves until you say so.** With `--dry-run` you get the diff and your files stay exactly as they were, so reading the suggestions costs nothing. ## Install Rector > **You can try Rector without installing it:** > > Rector is one of the tools shipped in the [jakzal/phpqa](/blog/php-jakzal-phpqa) Docker image and it comes preinstalled in the [PHP devcontainer](/blog/php-devcontainer). If you just want to see what it says about your code, start there. When you use composer to manage dependencies in your project, just run `composer require rector/rector --dev` in a console. This will install Rector as a dev-dependency (which is pretty good). The next thing is to create a configuration file. You can use a standard one (to do this just run `vendor/bin/rector` and the file will be created) or create yours. I suggest creating a `rector.php` file in your project with this content: **rector.php** ```php withSkip( [ '.git', 'node_modules','vendor' ] ) ->withPhpSets(php82: true) ->withPreparedSets(codeQuality: true, deadCode: true, typeDeclarations: true); ``` For simplicity, create the `rector.php` file in your project's root directory. ## Run Rector The command that produced the screenshot above is `vendor/bin/rector process sayHello.php --dry-run --config rector.php`. Let's take a look at it: * `vendor/bin/rector`: this is the executable to start, * `process`: the action to run, it's always `process`, * `sayHello.php`: the file to scan. You can type `.` for your entire project or f.i. `app` when `app` is a folder, * `--dry-run`: ask Rector to, only, show suggestions and not to update the file and * `--config rector.php`: this is the name of our configuration file. So running that command is just like asking: **please take a look at my sayHello.php script and show me how I can improve my syntax.** And Rector does it; quickly and without judgment, showing the changes it **could** make and explaining why it thinks they're better. ## Applied rules Take a look at the `Applied rules:` section at the end of the screen capture; here are the rules with a link to their explanations: * [SimplifyIfElseToTernaryRector](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#simplifyifelsetoternaryrector) * [SimplifyUselessVariableRector](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#simplifyuselessvariablerector) * [ReturnTypeFromStrictScalarReturnExprRector](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#returntypefromstrictscalarreturnexprrector) * [StrictStringParamConcatRector](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#strictstringparamconcatrector) ### SimplifyIfElseToTernaryRector Using the `ternary operator`, we can often replace a `if then else` structure with a one-line test; the `ternary operator`. ```diff function sayHello($firstname = '') { - if ($firstname == '') { - $text = 'Hello World'; - } else { - $text = 'Hello ' . $firstname; - } // highlight-next-line + $text = $firstname == '' ? 'Hello World' : 'Hello ' . $firstname; return $text; } ``` ### SimplifyUselessVariableRector I think everyone will agree: storing the value in variable `$text` then returned the variable is useless and it's just pollution. ```diff function sayHello($firstname = '') - $text = $firstname == '' ? 'Hello World' : 'Hello ' . $firstname; - return $text; // highlight-next-line + return $firstname == '' ? 'Hello World' : 'Hello ' . $firstname; }; ``` ### ReturnTypeFromStrictScalarReturnExprRector When the return type can be derived (here, Rector understand we're returning a string) then the function prototype can be adapted in this way. ```diff class SomeClass { - public function sayHello($firstname = '') // highlight-next-line + public function sayHello($firstname = ''): string { return $firstname == '' ? 'Hello World' : 'Hello ' . $firstname; } } ``` ### StrictStringParamConcatRector When a parameter is a string but not type as a string, Rector see it and suggest updating the prototype as such: ```diff class SomeClass { - public function sayHello($firstname = ''): string // highlight-next-line + public function sayHello(string $firstname = ''): string { return $firstname == '' ? 'Hello World' : 'Hello ' . $firstname; } } ``` ## Final code So, back to our console, running `vendor/bin/rector process sayHello.php --dry-run --config rector.php` will show me, in the console, the new *refactored* version of my code: **index.php** ```php **Am I happy with this? Oh yes!!!:** > > Yes, I'm very happy and proud to see how my code is now better and easier to read. > > Yes, I'm happy to have learned something and, now, I'll no more use a temporary variable or think to use the ternary operator. > > Yes, I'm very grateful to Rector for teaching me these new approaches and making me a better programmer. ## Last thing, make the change Now that we've taken the time to analyze the rules that could be applied and that we think are perfectly adequate, it's time to start refactoring for real: simply remove the `--dry-run` flag and, thus, the final command to run is: `vendor/bin/rector process sayHello.php --config rector.php`. Imagine what it could do for my entire project if I run `vendor/bin/rector process . --dry-run --config rector.php`. ## Conclusion A linter tells you that a line is wrong. Rector tells you what it would write instead, and names the rule so you can go read why. That's the difference between a tool that grades you and a coach that teaches you β€” and after a few sessions, you stop writing the useless temporary variable in the first place. There are more than 400 rules right now; see all of them at , and visit to learn how to configure `rector.php` for your own project. Rector is just one piece of my PHP code-quality toolbox; see also [formatting poorly formatted PHP code](/blog/online-php-linter). --- # Docker image that provides static analysis tools for PHP > Use the jakzal/phpqa Docker image to instantly run dozens of static analysis and code quality tools for your PHP codebase, including linters and fixers, without any installation. --- ![Docker image that provides static analysis tools for PHP](/img/v2/clean_code.webp) **TL;DR** This article shows how to run a whole suite of PHP static analysis tools with zero installation via the `jakzal/phpqa` Docker image: `composer normalize` to reorder `composer.json`, `composer-unused` to spot unused dependencies, `parallel-lint` for syntax errors, `php-cs-fixer` and `phpcbf` for auto-formatting β€” plus how to pin a specific PHP version tag when scanning legacy codebases. For years now, I'm using to run a lot of static analysis tools on my PHP codebase. The list of available tools is huge; see by yourself: [Available tools](https://github.com/jakzal/phpqa?tab=readme-ov-file#available-tools). In this blog post, we will see how we can take advantage of all these tools and increase the quality of our scripts. First, as always when we need to learn new software, we must talk about how to install it. The answer is easy here: there is nothing to do. Oh? Really? For sure! `jakzal/phpqa` is a Docker image so we don't need to install it, just to run it once. During the first call, Docker will download it so, yeah, no installation at all. Thank you Docker! > **Docker CLI reminder:** > > As a reminder, the used Docker run command will always look like: > > * `docker run` to run a Docker image (*Oh? Really?*), > * `-it` to start Docker interactively, this will allow the script running in the container to ask you for some prompts f.i., > * `--rm` to ask Docker to kill and remove the container as soon as the script has been executed (otherwise you'll have a lot of exited but not removed Docker containers; you can check this by not using the `--rm` flag then running `docker container list` on the console), > * `-v "${PWD}":/project` to [share your current folder](/blog/docker-volume) with a folder called `/project` in the Docker container (use `${PWD}` if you are on Linux, use `%CD%` if you are on DOS), > * `-w /project` to tell Docker that the current directory, in the container, will be the `/project` folder > * then `jakzal/phpqa` which is the name of the Docker image to use (you can also specify a version like `python:3.9.18` if needed; see ) ## Composer unused [Composer unused](https://github.com/composer-unused/composer-unused) will **try** to understand which packages are mentioned in your `composer.json` file and, perhaps, not used anymore. By running `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa composer-unused` in your project, you'll get something like this: ![composer unused](./images/composer_unused.webp) So, the tool detects that I'm referencing `spatie/laravel-db-snapshots` in my `composer.json` file but, in my codebase, the tool (in fact, the namespace added by the tool) isn't used at all so, yes, probably, I can remove that dependency. > **Caution:** > > Such a tool can produce false positive; just make some checks before removing the dependency. The best solution here is, of course, to have plenty of [unit tests](/blog/pest_tips) so you can run them before and after the change and see if your code still works as expected. ## Composer normalize [Composer normalize](https://github.com/ergebnis/composer-normalize) will reorder your `composer.json` file to match best practices. As you know, in a JSON file, there is no order so you can define your properties in any order. For instance: **composer.json** ```json { "name": "ergebnis/composer-normalize", "description": "Provides a composer plugin for normalizing composer.json.", "license": "MIT", "type": "composer-plugin" } ``` or **composer.json** ```json { "license": "MIT", "description": "Provides a composer plugin for normalizing composer.json.", "type": "composer-plugin", "name": "ergebnis/composer-normalize" } ``` or anything else. The above example is just four lines; imagine a file with a hundred lines. It's more logical, isn't it, to retrieve first the `name` of the tool, then a small `description`, the `license`, the `type` (is it a project, a library, ...) and so on and not, f.i., first the list of requirements. Ok, so, to normalize your `composer.json` file, you can install the tool or, and this is the objective of this blog post, simply run `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa composer normalize` and that's all. The result will be a rewritten `composer.json` file where properties are put in standard order. > **Information:** > > Add `--dry-run` if you just want to see what the changes will be; but do not modify any files. ## PHP-Parallel-lint As a third example, we'll see [PHP-Parallel-Lint](https://github.com/php-parallel-lint/PHP-Parallel-Lint). This tool will scan all `.php` files present in your codebase and ensure there is no syntax error like a missing `}`. The command to run here is `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa parallel-lint . --exclude vendor`. Think about adding exclusions like `--exclude vendor` so you don't scan code that isn't yours. As you can see below, in less than two seconds, the tool has checked 823 files of my codebase. Pretty fast. ```bash PHP 8.3.3 | 10 parallel jobs ............................................................ 60/823 (7 %) ............................................................ 120/823 (14 %) ............................................................ 180/823 (21 %) ............................................................ 240/823 (29 %) ............................................................ 300/823 (36 %) ............................................................ 360/823 (43 %) ............................................................ 420/823 (51 %) ............................................................ 480/823 (58 %) ............................................................ 540/823 (65 %) ............................................................ 600/823 (72 %) ............................................................ 660/823 (80 %) ............................................................ 720/823 (87 %) ............................................................ 780/823 (94 %) ........................................... 823/823 (100 %) ``` ## PHP-CS-FIXER Another tool, [PHP-CS-FIXER](https://cs.symfony.com/). *The PHP Coding Standards Fixer (PHP CS Fixer) tool fixes your code to follow standards.* Here the command will be `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa php-cs-fixer fix` to reformat all your codebase according to the selected rulesets. If none is mentioned, `PSR-12` will be the default one. ## PHP\_CodeSniffer Another tool, [PHP\_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer). *PHP\_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.* This time, the command will be `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa phpcbf .` ## A whole host of other tools await you As you can see, we can use a lot of tools using `jakzal/phpqa` in a zero-installation schema. That's really impressive. You can find a lot more tools than described in this article, just go to [Available tools](https://github.com/jakzal/phpqa?tab=readme-ov-file#available-tools) and see which ones can help you. Two natural next steps once you've picked your tools: run them **before** committing, thanks to [Git pre-commit hooks](/blog/git-precommit), and run them again in your CI, as described in [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker). ## Change PHP versions Let's imagine your codebase is a legacy one (not written for PHP 8.3 but f.i. PHP 7.4). An instruction like `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa parallel-lint . --exclude vendor` (as we have seen above) will scan the code with PHP 8.3 (situation end March 2024) and you can have plenty of errors since you're using a PHP 8.3x tool on a PHP 7.4x codebase. How to solve this? Really easy in fact. As you know, by using `docker run [...] jakzal/phpqa [...]` you're asking Docker to use the `latest` version of the image and that version will change. Every time the owner of the image publishes a newer version, that `latest` image is updated and perhaps, in a few months, it'll be PHP 8.4 by default. So, to solve our issue: we need to use a specific tag for PHP 7.4. *And if the tools complain about your PHP settings rather than your code, see [Update php.ini when using a Docker image](/blog/docker-php-ini).* Simply jump on and type `7.4` in the `Filter Tags` area. ![Tags](./images/tags.webp) You'll find some tags. Now you can start `docker run -it --rm -v "${PWD}":/project -w /project jakzal/phpqa:1.80-php7.4-alpine parallel-lint . --exclude vendor` to run the PHP 7.4 linter. --- # Clean code - Linux Bash - Keep the number of function parameters as small as possible > Apply Clean Code to your Linux Bash scripts. Discover how to avoid too many function parameters by using a simple, robust associative array solution. --- ![Clean code - Linux Bash - Keep the number of function parameters as small as possible](/img/v2/bash.webp) **TL;DR** This article applies the clean-code principle of limiting function parameters to Bash, where passing an object isn't possible: instead, pass a single associative array (`declare -A arr=()`) to the function so callers don't have to track positional argument order, and new fields can be added later without breaking existing call sites. A concept of the clean code approach is to avoid too many function parameters (I would say that four parameters is already too many). When you're programming in a language more advanced than Linux Bash, it's easy to get round the problem. For example, in PHP, if I need to call a function and pass it several parameters, I'll create an object that will be my only parameter and that object will then have several properties. So, for instance, if I need to pass data such as a surname, first name, date of birth, gender, etc., I'll create a `person` object, define my properties and voilΓ , I've only got one parameter to pass to my function. In most cases, it'll be a very nice solution. But how do you do it in Linux Bash? It's impossible to pass an object... so I'm going to pass it an associative array. The trick, for a Bash script, is to define an associative array (created with the command `declare -A arr=()`) and pass this array as a parameter to the function. Below is an example: **curl.sh** ```bash #!/usr/bin/env bash # let script exit if an unused variable is used or if a command fails set -o nounset set -o errexit function runCurl() { local -n arrCall=$1 # The endpoint (the URL to request) is mandatory local -r endpoint="${arrCall["endpoint"]}" # The other properties are optional; initialize to an empty string or any other default values [[ ! -v arrCall[header] ]] && local -r header="" || local -r header="${arrCall["header"]}" [[ ! -v arrCall[method] ]] && local -r method="GET" || local -r method="${arrCall["method"]}" [[ ! -v arrCall[payload] ]] && local -r payload="" || local -r payload="${arrCall["payload"]}" [[ ! -v arrCall[proxy] ]] && local -r proxy="" || local -r proxy="${arrCall["proxy"]}" [[ ! -v arrCall[token] ]] && local -r token="" || local -r token="${arrCall["token"]}" # And now I can work with my multiple variables exactly as I would have done if I'd passed them all as parameters. # # Here is an example: cmd="curl -X ${method} ${endpoint} --no-progress-meter" [[ -n ${payload} ]] && cmd="${cmd} --data ${payload}" [[ -n ${header} ]] && cmd="${cmd} --header ${header}" [[ -n ${token} ]] && cmd="${cmd} ${token}" [[ -n ${proxy} ]] && cmd="${cmd} --proxy ${proxy}" if ! json="$(eval "${cmd}")"; then echo "CURL has failed" exit 1 fi echo "${json}" return 0 } declare -A arr=( [endpoint]=https://jsonplaceholder.typicode.com/todos/5 [token]="--header 'Authorization:Bearer ABCDEF'" ) # As you know, when using variables you should use the syntax like ${variable} # But, here below, no. We're passing our array and we should not use the `$` runCurl arr ``` One of the big advantages is that you don't have to worry about the position of the arguments (ah yes, so the first parameter is the url, the second is the HTTP method, the third is, er? the proxy? ah no, damn). New parameters can also be created in the future without affecting existing scripts in any way. Everything is much cleaner using an associative array and more robust too. Two other habits that keep a Bash codebase clean: [sorting your functions](/blog/linux-sort-functions-in-script) so they're findable, and [covering them with bats unit tests](/blog/bats-unit-tests) so a refactoring like this one can be done without holding your breath. --- # SQL - Formatting tool > Easily clean up unreadable, legacy SQL code with a simple formatting tool. Make your SQL queries instantly readable and kickstart your code review process. --- ![SQL - Formatting tool](/img/v2/sql_format.webp) **TL;DR** This article introduces the author's free, self-hosted SQL Formatter tool (source on GitHub, hosted demo at sql-formatter.avonture.be), which turns unreadable single-line legacy SQL queries into properly indented, readable statements β€” a useful first step before reviewing old database code. When faced with legacy code, it is often useful to reformat it to make it readable. And from there, the study of the code can begin. There are plenty of reformatting tools for [json](/blog/json-lint), [php](/blog/online-php-linter), javascript and other languages, but far fewer for a query written in SQL. Just copy/paste `SELECT LAT_N, CITY, TEMP_F FROM STATS, STATION WHERE MONTH = 7 AND STATS.ID = STATION.ID ORDER BY TEMP_F` in the tool and get ```sql SELECT LAT_N, CITY, TEMP_F FROM STATS, STATION WHERE MONTH = 7 AND STATS.ID = STATION.ID ORDER BY TEMP_F ``` Retrieve my **SQL Formatter** tool and sources on . The tool is accessible online: ![Demo](./images/sql_formatter_demo.gif) I've built a couple of similar self-hosted, single-purpose tools: [JSON - Online linter](/blog/json-lint) and [Excel Formula Beautifier](/blog/excel-formatter). --- # JSON - Online linter > Find a quick and easy online JSON linter tool. Paste your JSON string to display it as a clear, readable tree-view with code folding features. --- ![JSON - Online linter](/img/v2/json.webp) **TL;DR** This article introduces the author's free JSON Linter tool (source on GitHub, hosted at jsonlint.avonture.be) for pasting a JSON string and viewing it as a foldable, readable tree β€” useful for both syntax-checking JSON you're writing and inspecting API responses. It also mentions the Chrome "JSON Formatter" extension for auto-formatting JSON pages in the browser. Exactly like my [SQL - Formatting tool](/blog/sql-formatter), it's always useful to have a tool in your favorites that lets you copy/paste a JSON character string and display it as a tree-view with or without a code folding feature. I use it quite regularly when I'm writing a JSON string and I want to check that there are no syntax errors (lint) or, on the other hand, and for example, when I call an API that returns JSON and I want to analyze the code received. In these cases, the collapse functionality comes in very handy. *Two alternatives depending on the context: [`jq`](/blog/linux-jq) when you're already in a console, and [Rendering a JSON file as a mind map](/blog/json-crack) when the structure matters more than the values. To produce test JSON rather than read it, see [JSON - Faker & Mockup](/blog/json-faker).* Retrieve my **JSON Linter** tool and sources on . The tool is accessible online: ![Demo](./images/json_lint_demo.gif) ## Chrome Addon If you're using Chrome and if the web page output is a JSON string, the [JSON Formatter](https://chromewebstore.google.com/detail/json-formatter/bcjindcccaagfpapjjmafapmmgkkhgoa) addon will immediately display it in a readable form. For instance, instead of getting this page: ![Json webpage](./images/json_page.webp) Chrome will show this: ![Chrome addon](./images/chrome_addon.webp) Other JSON tools worth knowing about: [rendering a JSON file as a mind map](/blog/json-crack) and [generating fake JSON data](/blog/json-faker). --- # Format poorly formatted PHP code > Need to clean up messy PHP code fast? Discover the best online PHP linter, Pint-express (based on Laravel Pint), to instantly format and beautify your code. No installation needed! --- ![Format poorly formatted PHP code](/img/v2/clean_code.webp) **TL;DR** This article points to Pint-express (benjamincrozat.com/pint-express), an online PHP formatter based on Laravel Pint, for instantly cleaning up poorly formatted PHP code by pasting it into a textarea β€” no install needed. For ongoing projects, it recommends wiring up PHP-CS-Fixer or PHP\_CodeSniffer in the editor, build process, or CI pipeline instead. You're recovering some old PHP code; you want to answer a question asked on a forum and the person who posted the PHP question didn't take care to ensure that it was properly formatted; ... there are too many occasions when the syntactic quality of the code can be rotten. You'd like to have an online tool that you can use to quickly retrieve code with a much more polished layout, just by copying and pasting. Without having to install anything and without any headaches. For example, how do you make the code below look cleaner in five seconds? **my\_collection.php** ```php $val){$object->$key = $val;} $this->storage[] = $object; }} } ``` There are a huge number of *linters* on the internet, but this is perhaps one of the best: . It's based on the [Laravel Pint](https://laravel.com/docs/13.x/pint) tool (but not limited to Laravel code for sure). So, here's how the code looked before: ![Correctly formatted PHP code](./images/before.webp) Jump to [Hexmos Pint formatter](https://hexmos.com/freedevtools/tldr/common/pint), copy the code into the **Code** textarea of the script and let the reformatting happen: ![Correctly formatted PHP code](./images/after.webp) Much better. Note: there are other tools like for instance . An online tool is perfect for a one-off paste. For your own projects, wire the formatter into your workflow instead: [jakzal/phpqa](/blog/php-jakzal-phpqa) gives you `php-cs-fixer` and `phpcbf` without installing anything, and [Git - pre-commit-hooks](/blog/git-precommit) runs them before each commit. ## If you're a PHP developer ... then make sure to use tools like [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) or [PHP\_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) or many others. See also my [Docker image that provides static analysis tools for PHP](/blog/php-jakzal-phpqa). Remember to add these tools to your editor (there are many VSCode extensions), to your workflow (f.i. by using local `make` actions), or add these steps to a remote pipeline. --- # Python - Code Quality tools > Elevate your Python code quality! Discover 9 essential static analysis tools like Pylint, Black, and mypy, plus an optimal QA workflow to ensure cleaner, bug-free code. --- ![Python - Code Quality tools](/img/v2/clean_code.webp) **TL;DR** This article catalogs the author's Python code-quality toolchain β€” Pylint, Autoflake, isort, Vulture, pydocstyle, mypy, Pyright, Black, and Prospector, plus Ruff as an emerging fast all-in-one alternative β€” with the exact CLI command and config file for each. They're chained together, fastest/most fundamental first, in a `make qa` target that stops at the first failure and only prints "CONGRATULATIONS" when every check passes. If you're a self-respecting programmer, you can't develop without code analysis tools. For PHP programmers, you already know a lot of them (`rector`, `phpstan`, `phan`, `phpcs`, ...). See my previous articles [Rector 1.0.0, my friend, my coach](/blog/php-rector) and [Docker image that provides static analysis tools for PHP](/blog/php-jakzal-phpqa). What about Python? ## The Result: One Command, Nine Checks, One Clear Failure Point `make qa` chains Pylint, Autoflake, isort, Vulture, pydocstyle, mypy, Pyright, Black and Prospector β€” fastest tool first β€” and stops at the very first failure. Here's what a clean run looks like: ```bash $ make qa Step Tool Run? Techno Description -------------------------------------------------------------------------------------------------------------- 1/9 RUNNING make Pylint Lint python scripts using Pylint (https://pypi.org/project/pylint/) Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) 2/9 RUNNING make Autoflake Detect unused variables and unused imports (https://pypi.org/project/autoflake/) 3/9 RUNNING make isort Sort import statement using isort (https://pycqa.github.io/isort/) 4/9 RUNNING make Vulture Find dead Python code (https://github.com/jendrikseipp/vulture) 5/9 RUNNING make Pydocstyle pydocstyle is a static analysis tool for checking compliance with Python docstring conventions (https://www.pydocstyle.org/en/stable/) 6/9 RUNNING make Mypy Mypy is a program that will type check your Python code (https://github.com/python/mypy/) Success: no issues found in 12 source files 7/9 RUNNING make Pyright Pyright is a full-featured, standards-based static type checker for Python (https://github.com/microsoft/pyright) 0 errors, 0 warnings, 0 informations 8/9 RUNNING make Black Format the script using Black (https://black.readthedocs.io/en/stable/) All done! ✨ 🍰 ✨ 12 files left unchanged. 9/9 RUNNING make Prospector Inspects Python source files and provides information about type and location of classes, methods, ... (https://github.com/prospector-dev/prospector/) Messages Found: 0 CONGRATULATIONS!!! Not the slightest error or notice detected, that's .. amazing! πŸ₯³ πŸŽ‰ 🎊 🀩 πŸ•Ί πŸ’ƒ πŸ‘ ``` Nothing green to look for: silence (or a rating like Pylint's `10.00/10`) means the check passed. Miss a single one and the chain stops right there β€” no **CONGRATULATIONS**, and only one thing to fix before re-running. ## Why It Works * Tools run fastest/most fundamental first: no point running a slow type-checker if the code doesn't even parse β€” that's why Pylint is 1/9 and Autoflake, cleaning up imports, is 2/9. * The chain stops at the first failure: one `make qa` run, one clear thing to fix, not nine reports to reconcile. * `make qa` is a single entry point: the same target is what you run by hand, what a pre-commit hook runs, and what CI runs β€” more on that in the conclusion. ## Installation I'm a big fan of static code quality tools and here is my short list, wired together with a [makefile](/blog/tags/makefile) action called `qa`: **makefile** ``` # cspell:disable .PHONY: qa qa: @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " Step Tool" "Run?" "Techno" "Description" @echo "--------------------------------------------------------------------------------------------------------------" @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 1/9 RUNNING make Pylint" "Lint python scripts using Pylint (https://pypi.org/project/pylint/)" @pylint . --rcfile .config/.pylintrc @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 2/9 RUNNING make Autoflake" "Detect unused variables and unused imports (https://pypi.org/project/autoflake/)" @autoflake --remove-unused-variables --remove-all-unused-import --recursive . @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 3/9 RUNNING make isort" "Sort import statement using isort (https://pycqa.github.io/isort/)" @isort . @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 4/9 RUNNING make Vulture" "Find dead Python code (https://github.com/jendrikseipp/vulture)" @vulture --min-confidence 100 . @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 5/9 RUNNING make Pydocstyle" "pydocstyle is a static analysis tool for checking compliance with Python docstring conventions (https://www.pydocstyle.org/en/stable/)" @pydocstyle --config=.config/.pydocstyle @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 6/9 RUNNING make Mypy" "Mypy is a program that will type check your Python code (https://github.com/python/mypy/)" @mypy --config-file .config/.mypy.ini . @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 7/9 RUNNING make Pyright" "Pyright is a full-featured, standards-based static type checker for Python (https://github.com/microsoft/pyright)" @pyright --project .config/pyright.json @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n" " 8/9 RUNNING make Black" "Format the script using Black (https://black.readthedocs.io/en/stable/)" @black --config .config/black.toml . @printf "\e[1;37m%-35s%-10s%-10s\e[m%s\n\n" " 9/9 RUNNING make Prospector" "Inspects Python source files and provides information about type and location of classes, methods, ... (https://github.com/prospector-dev/prospector/)" @prospector . --profile .config/prospector.yaml --pylint-config-file .config/.pylintrc @printf "%s\n" "CONGRATULATIONS!!!" @printf "%s\n" "Not the slightest error or notice detected, that's .. amazing!" @printf "%s\n" "πŸ₯³ πŸŽ‰ 🎊 🀩 πŸ•Ί πŸ’ƒ πŸ‘" ``` As soon as an error is detected, the script stops. You'll see the **CONGRATULATIONS** message, exactly as shown above, only if all nine checks are successful. ## More Demos: The Nine Tools, One by One ### 1. Pylint > > > [VSCode Addon](https://marketplace.visualstudio.com/items?itemName=ms-python.pylint) > > Pylint is a static code analyzer for Python 2 or 3. The latest version supports Python 3.9.0 and above. > > Pylint analyzes your code without actually running it. It checks for errors, enforces a coding standard, looks for code-smells, and can make suggestions about how the code could be refactored. First things first: make sure your Python code has no syntax errors β€” no bad indentation, no forgotten `:` at the end of a control statement (like an `if` or `for`). I'm running it like this: `pylint . --rcfile .config/.pylintrc`. **.config/.pylintrc** ``` [MASTER] ; Pickle collected data for later comparisons. persistent=yes ; List of plugins (as comma-separated values of python modules names) to load, ; usually to register additional checkers. load-plugins= pylint.extensions.check_elif, pylint.extensions.bad_builtin, pylint.extensions.docparams, pylint.extensions.for_any_all, pylint.extensions.set_membership, pylint.extensions.code_style, pylint.extensions.overlapping_exceptions, pylint.extensions.typing, pylint.extensions.redefined_variable_type, pylint.extensions.comparison_placement, ; Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the ; number of processors available to use. jobs=0 ; When enabled, pylint would attempt to guess common misconfiguration and emit ; user-friendly hints instead of false-positive error messages. suggestion-mode=yes ; Minimum supported python version py-version = 3.13.0 ; Specify a score threshold to be exceeded before program exits with error. fail-under=9.50 [FORMAT] max-line-length=120 ``` ### 2. Autoflake > > > Autoflake removes unused imports and unused variables from Python code. It makes use of pyflakes to do this. > > By default, Autoflake only removes unused imports for modules that are part of the standard library. (Other modules may have side effects that make them unsafe to remove automatically.) Removal of unused variables is also disabled by default. > > Autoflake also removes useless pass statements by default. I'm running it like this: `autoflake --remove-unused-variables --remove-all-unused-import --recursive .` ### 3. isort > > > [VSCode Addon](https://marketplace.visualstudio.com/items?itemName=ms-python.isort) > > `isort` your imports, so you don't have to. > > isort is a Python utility / library to sort imports alphabetically, and automatically separates into sections and by type. It provides a command line utility, Python library and plugins for various editors to quickly sort all your imports. It requires Python 3.7+ to run but supports formatting Python 2 code too. I'm running it like this: `isort .` Note: I've also configured my VSCode with this setting `"python.sortImports.args": ["--profile", "black"]` so import statements are automatically sorted while I'm coding. ### 4. vulture > > > Vulture finds unused code in Python programs. This is useful for cleaning up and finding errors in large code bases. If you run Vulture on both your library and test suite you can find untested code. > > Due to Python's dynamic nature, static code analyzers like Vulture are likely to miss some dead code. Also, code that is only called implicitly may be reported as unused. Nonetheless, Vulture can be a very helpful tool for higher code quality. I'm running it like this: `vulture --min-confidence 100 .` > **Danger:** > > Be careful with Vulture because its algorithm will detect a lot of false positives so this is why, in my automation process, I've used `--min-confidence 100` to make sure I've **only** real unused code / variables. Note: I've also configured my VSCode with the settings below so, while I'm coding, VSCode will notify me about unused things so I can immediately take action. **.vscode/settings.json** ```json "python.analysis.diagnosticSeverityOverrides": { "reportUnusedClass": "information", "reportUnusedFunction": "information", "reportUnusedImport": "information", "reportUnusedVariable": "information" }, ``` ### 5. pydocstyle > > > `pydocstyle` is a static analysis tool for checking compliance with Python docstring conventions. This tool checks the quality of your comments, such as your function descriptions, and, for example, makes sure that if you have a function with two arguments, your description explains both arguments (there's a check on the name and the type). I'm running it like this: `pydocstyle --config=.config/.pydocstyle` **.config/.pydocstyle** ``` [pydocstyle] ignore = D100,D203,D205,D212,D213,D400,D404,D406,D407,D413,D415 match = .*\.py ``` > **Caution:** > > The pydocstyle GitHub repository was archived in November 2023 and is no longer actively maintained. The community recommends migrating to **Ruff** (see the "Under the Hood" section below), which covers equivalent docstring checks via its `pydocstyle`-compatible rule set. ### 6. mypy > > > Mypy is a static type checker for Python. > > Type checkers help ensure that you're using variables and functions in your code correctly. With mypy, add type hints (PEP 484) to your Python programs, and mypy will warn you when you use those types incorrectly. > > Python is a dynamic language, so usually you'll only see errors in your code when you attempt to run it. Mypy is a static checker, so it finds bugs in your programs without even running them! I'm running it like this: `mypy --config-file .config/.mypy.ini .` **.config/.mypy.ini** ```ini [mypy] show_error_codes = true ; First we turn on *all the checks*, and then we turn off those that are too annoying. strict = True cache_dir = /tmp/mypy no_implicit_optional = true strict_equality = true warn_redundant_casts = true warn_unused_ignores = true python_version = 3.13 ``` ### 7. Pyright > > > Pyright is a full-featured, standards-based static type checker for Python. It is designed for high performance and can be used with large Python source bases. I'm using it like this: `pyright --project .config/pyright.json` **.config/pyright.json** ```json { "include": [ "." ], "pythonVersion": "3.13", "pythonPlatform": "Linux" } ``` ### 8. Black > > > [VSCode Addon](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter) > > By using Black, you agree to cede control over minutiae of hand-formatting. In return, Black gives you speed, determinism, and freedom from pycodestyle nagging about formatting. You will save time and mental energy for more important matters. > > Black makes code review faster by producing the smallest diffs possible. Blackened code looks the same regardless of the project you’re reading. Formatting becomes transparent after a while and you can focus on the content instead. I'm using it like this: `black --config .config/black.toml .` **.config/black.toml** ```toml [tool.black] # The same as in prospector.yaml and .pylintrc line-length = 120 # We'll format the code for Python 3.13.0; we can add multiple versions (the ones supported by # the script, all versions separated by a comma like in ['py312', 'py313'] (run "black --help" to get them) target-version = ['py313'] ``` ### 9. prospector > > > Inspects Python source files and provides information about type and location of classes, methods etc. I'm using it like this: `prospector . --profile .config/prospector.yaml --pylint-config-file .config/.pylintrc` **.config/prospector.yaml** ```yaml strictness: high test-warnings: true doc-warnings: false pycodestyle: options: max-line-length: 120 disable: - D100 - D203 - D205 - D212 - D213 - D400 - D404 - D406 - D407 - D413 - D415 bandit: run: true dodgy: run: true pyflakes: run: true mccabe: run: true ``` ## Under the Hood (skip this if you just want to use it) ### Extra - Ruff, a potential replacement for several tools > > > Ruff, an extremely fast Python linter and code formatter, written in Rust. > > [VSCode Addon](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) Right now, I've too little experience with Ruff so I prefer to add it as **extra**. The documentation mentions it is extremely fast and I should admit the first time I've run it I was thinking nothing was done (really too fast). I've made an intentional error in my code and it was detected correctly so, yes, the tool is extremely fast. According to the Ruff documentation, this tool can completely replace Pylint (the linter), Autoflake (deletion of unused imports/variables), Black (the formatting tool) but does not replace MyPy and Pyright (doc). I'm using it like this: `ruff format --cache-dir /tmp/ruff --config .config/pyproject.toml .` and `ruff check --cache-dir /tmp/ruff --config .config/pyproject.toml .` **.config/pyproject.toml** ```toml [tool.ruff] exclude=[] # The same as in prospector.yaml, .pylintrc, black.toml and pyproject.toml line-length = 120 indent-width = 4 ``` ## Conclusion Nine tools, one command, one clear failure point β€” `make qa` turns "did I remember to check everything" into a single deterministic run, fastest/most fundamental check first, stopping the instant something's wrong. Running it by hand still relies on you remembering to do it, though. Two ways to make it automatic: [Git - pre-commit-hooks](/blog/git-precommit) refuses the commit when a check fails, and [Dagger.io - Using dagger to automate your CI workflows](/blog/dagger-python) runs the exact same steps locally and in your CI. --- # Git - pre-commit-hooks > Hate failed CI/CD pipelines? Discover how to use Git pre-commit hooks, focusing on the pre-commit framework, to enforce code quality standards locally before you push. --- ![Git - pre-commit-hooks](/img/v2/clean_code.webp) **TL;DR** Avoid failing CI/CD pipelines by catching errors before you commit. This article explains how to use Git pre-commit hooks to enforce code quality standards locally. It provides a practical guide to setting up and using the `pre-commit` framework, a versatile tool for managing multi-language hooks. You'll learn how to configure hooks for a Python project to automatically check for issues like inconsistent formatting, and how to install and run them. The article also covers how to find more hooks and when it's appropriate to bypass them. You like clean code, don't you? And you hate getting an email from your versioning tool (e.g. Github or GitLab) telling you that your last commit didn't go through, that the formatting of your code is bad; this is because you've left one space too many at the end of a line or you've used single quote instead of double (or the opposite), for example. You've pushed your changes, already started to work on another activity, perhaps another project and boom, two hours after your last commit (*because the CI server was working on a lot of pipelines before yours*), boom, you get a *Your last commit has failed, #gnagnagna*. I hate it as much as I love clean code. So, what should we do to avoid this? The answer is simple! Before each push, we should run the same code analysis tools that are executed in the CI, i.e. `phplint`, `php-cs-fixer`, `phpcbf`, `phan`, `phpstan`, ... (for PHP, all bundled in the [jakzal/phpqa Docker image](/blog/php-jakzal-phpqa)) or `pylint`, `mypy`, `prospector`, `black`, `ruff`, ... (for Python, see [Python - Code Quality tools](/blog/python-qa)) or `shellcheck` and `shellformat` (for Linux Bash) or ... We should, but do we think about it every time? Unfortunately not. Let's see how to correct this. ## The Result: A Commit, Reformatted Before It Even Happens Here is `pre-commit` catching (and fixing) a formatting violation, on a demo project you'll build below: ```bash $ pre-commit run --all-files ``` ```bash [INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks. [WARNING] repo `https://github.com/pre-commit/pre-commit-hooks` uses deprecated stage names (commit, push) which will be removed in a future version. Hint: often `pre-commit autoupdate --repo https://github.com/pre-commit/pre-commit-hooks` will fix this. if it does not -- consider reporting an issue to that repo. [INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks. [INFO] Once installed this environment will be reused. [INFO] This may take a few minutes... Check Yaml...........................................(no files to check)Skipped Fix End of Files.....................................(no files to check)Skipped Trim Trailing Whitespace.............................(no files to check)Skipped black................................................(no files to check)Skipped # git add . # pre-commit run --all-files Check Yaml...............................................................Passed Fix End of Files.........................................................Passed Trim Trailing Whitespace.................................................Passed black....................................................................Failed - hook id: black - files were modified by this hook reformatted main.py All done! ✨ 🍰 ✨ 1 file reformatted. ``` Did you see the **reformatted main.py** line? A quoting inconsistency (`"double"` vs `'single'` quotes) got silently fixed by `black`, right there in the local stage: ![Black has reformatted our script](./images/black.webp) No CI run, no email two hours later β€” the violation never leaves your machine. ## Why It Works Git ships two hook stages, `pre-` and `post-`; a `pre-commit` hook runs *before* the commit is created and can abandon it entirely if something fails. Among the tools that build on this ([husky](https://github.com/typicode/husky), [pre-commit](https://github.com/pre-commit/pre-commit), [grumphp](https://github.com/phpro/grumphp), [CaptainHook](https://github.com/captainhookphp/captainhook)), this article uses **pre-commit**: * It's multi-language β€” PHP, Python, Bash, whatever the project needs β€” one config file. * It runs the same tools your CI already runs (`black`, `pylint`, `phpstan`, ...), just earlier and for free, with no runner to wait for. * Once installed, it re-checks automatically on every `git commit` β€” nothing to remember, it's enforced. ## Installation We'll create a new temporary folder, run `git init` to initialise a project and create a Docker image and run a container for our demo. First, create a temporary folder and jump into it, then initialise it as a git repository (we'll work offline but, yes, to use git pre-commit hooks, we need a git project). ```bash $ mkdir /tmp/hooks && cd $_ $ git init ``` We'll need three files, a `Dockerfile` to create our Python Docker image, a `compose.yaml` to set some settings and `main.py` as a Python example script. **Dockerfile** ```docker # syntax=docker/dockerfile:1 FROM python:3.13-slim AS base RUN apt-get -y update \ && apt-get install --yes --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* WORKDIR "/app/src" # Keep the container running ENTRYPOINT ["tail", "-f", "/dev/null"] ``` **compose.yaml** ```yaml services: app_python: build: . volumes: - .:/app/src ``` **main.py** ```python print("I'm your Python code") print('Who you, who are you?') ``` We'll create our Docker image and create a container with this single command: `docker compose up --detach --build`. And, now, we'll jump in the container by running: `docker compose exec app_python /bin/sh`. And we can run our script: ```bash $ python main.py I'm your Python code Who you, who are you? ``` ### Installing pre-commit > For a Python project, it's really easy, you just need to run `pip install pre-commit`. ### Adding a configuration file Simple too, please create a file called `.pre-commit-config.yaml` with this content: **.pre-commit-config.yaml** ```yaml repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black ``` ### Manually fire the hook `pre-commit` can be manually fired but you should have some files in your git local stage. In this article, we've created a few files, please run `git add .` just to put them in the git local stage. Now, to manually start all controls defined in the yaml file simply run `pre-commit run --all-files` β€” that's the exact command and output shown at the top of this article. Did you notice my typo? ```python print("I'm your Python code") print('Who you, who are you?') ``` The first time I've used double quotes (in the first `print` statement) while I've used single ones in the second. So, I've (voluntary) created a code violation and the `black` tool has see it and reformatted `main.py`, exactly as shown above. ## More Demos ### Install hooks Ok, the idea wasn't to fire pre-commit hooks manually, right? Just run `pre-commit install` and, from now, every single time you'll run `git commit`, first, `pre-commit` controls will be made and only when all controls are successful (i.e. all will return an exit code of `0`), then your commit will be allowed. ### A few more hooks There are a lot of existing hooks and you can even create yours: * [Code spell](https://github.com/codespell-project/codespell/blob/main/.pre-commit-config.yaml#L70), *Check code for common misspellings* * [Git leaks](https://github.com/gitleaks/gitleaks/blob/master/.pre-commit-hooks.yaml), *Find secrets with Gitleaks* * [Git lint](https://github.com/jorisroovers/gitlint), *Linting for your git commit messages* * [Markdown format](https://github.com/hukkin/mdformat/blob/master/.pre-commit-config.yaml), *CommonMark compliant Markdown formatter* * [Markdownlint](https://github.com/markdownlint/markdownlint/blob/main/.pre-commit-hooks.yaml), *Markdown lint tool* * [Ruff](https://github.com/astral-sh/ruff-pre-commit/blob/main/.pre-commit-hooks.yaml), *A pre-commit hook for Ruff.* * [Shell check](https://github.com/shellcheck-py/shellcheck-py?tab=readme-ov-file#as-a-pre-commit-hook), *python3/pip3 wrapper for installing shellcheck* * [Trufflehog](https://github.com/trufflesecurity/trufflehog/blob/main/.pre-commit-config.yaml), *Find, verify, and analyze leaked credentials* ### A ready-made Python 3.13 config For a Python 3.13 project, here is my `.pre-commit-config.yaml` file: **.pre-commit-config.yaml** ```yaml default_language_version: python: python3.13 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-ast # Python linter - Simply check whether files parse as valid python. - id: check-docstring-first # No code before the initial docstring - id: check-json # JSON linter - Attempts to load all json files to verify syntax. - id: check-toml # TOML linter - Attempts to load all TOML files to verify syntax. - id: check-xml # XML linter - Attempts to load all XML files to verify syntax. - id: check-yaml # YAML linter - Attempts to load all yaml files to verify syntax. - id: check-merge-conflict # Check for files that contain merge conflict strings. - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. - id: file-contents-sorter files: \.config/*.txt$ # Sort all lines in any .config/*.txt file (like cspell.txt f.i.) args: [--ignore-case] - id: no-commit-to-branch args: [--branch, main] # Don't allow to push directly on the main branch - id: requirements-txt-fixer # Sorts entries in requirements.txt - id: trailing-whitespace # Trims trailing whitespace. - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black # Python black formatter - repo: local hooks: - id: isort # isort (should be installed using `pip install isort` or mentioned in the `requirements.txt` file) name: isort entry: isort language: system types: [python] require_serial: true args: ["--line-length 120"] - id: mypy # Mypy (should be installed using `pip install mypy` or mentioned in the `requirements.txt` file) name: mypy entry: mypy language: system types: [python] require_serial: true args: ["--config-file=.config/mypy.ini"] - id: pylint # Pylint (should be installed using `pip install pylint` or mentioned in the `requirements.txt` file) name: pylint entry: pylint language: system types: [python] require_serial: true args: ["--rcfile=.config/.pylintrc"] ``` ## Under the Hood (skip this if you just want to use it) > **Note:** > > If you're curious about how it works, simply show the `.git/hooks/pre-commit` file. The `pre-commit install` instruction has configured git to execute a small Bash script called `.git/hooks/pre-commit`. ### Search for hooks and write your own Take time to surf on to see a few of them, or search on with queries like `context:global file:^\.pre-commit-hooks\.yaml$ "types: [python]"` f.i. ([direct link](https://sourcegraph.com/search?q=context:global+file:%5E%5C.pre-commit-hooks%5C.yaml%24+%22types:+%5Bpython%5D%22\&patternType=keyword\&sm=0)). As illustrated on , you can add local hooks. Imagine, you've already installed a tool like `prospector` (for Python) or `phpstan` (for PHP). These tools are installed on your machine (so you can call them on the command line). So, simply add a new hook like this: **.pre-commit-config.yaml** ```yaml - repo: local hooks: - id: prospector name: prospector entry: prospector language: system ``` You can also pass arguments: **.pre-commit-config.yaml** ```yaml - repo: local hooks: - id: mypy # Mypy (should be installed using `pip install mypy`) name: mypy entry: mypy language: system types: [python] require_serial: true args: ["--config-file=.config/mypy.ini"] ``` ## Tip: --no-verify In some situation, you've to push your changes even if there are some code violation. Let's say, it's your last hour before three weeks holiday and you're working alone on a branch like `feat-user-profile`. You wish to push your changes and enjoy a break. In that situation, you can add the `--no-verify` flag f.i. `git commit -m "wip: not yet finished" --no-verify`. And, then, pre-commit hooks won't be executed so your changes will be committed. > **The --no-verify flag:** > > Only use this flag if you know exactly what you're doing. It would be a very bad idea to do this f.i. to the `dev` branch if you're working in a team. ## Conclusion Those failed-CI emails from the introduction stop happening once the same checks run locally, before the commit exists β€” `pre-commit` turns "wait for the server to tell you" into "know immediately." Install it once (`pre-commit install`) and every future commit is covered, in any language your project mixes. Keep `--no-verify` in your back pocket for the rare deliberate exception, and see [Python - Code Quality tools](/blog/python-qa) for the tools this article's demo hooks are built on. --- # Validate your OpenAPI schema against the Belgif REST standards > Validate your FastAPI OpenAPI schema against the Belgif REST standards using Docker, with step-by-step fixes for the most common linting errors. --- ![Validate your OpenAPI schema against the Belgif REST standards](/img/v2/belgif.webp) **TL;DR**This post offers a practical guide to ensuring your FastAPI applications comply with the Belgif REST standards, a strict set of guidelines required for Belgian public sector APIs. Because FastAPI's default OpenAPI schema generation often conflicts with these rules, the author demonstrates how to set up the official Belgif linter inside an isolated Docker container to easily identify compliance errors. Furthermore, the article provides actionable, step-by-step solutions and custom Python helper functions to resolve common FastAPI linting violationsβ€”such as downgrading the OpenAPI version, removing redundant title properties, and fixing camelCase naming conventionsβ€”ensuring your API documentation passes validation smoothly. Building APIs is relatively easy, but building *compliant* and *interoperable* APIs is an entirely different challenge. If you are working on projects for the Belgian government or public sector, you have likely encountered the **[Belgif REST standards](https://www.belgif.be/specification/rest/api-guide/)**. These comprehensive guidelines ensure that APIs across various institutions remain consistent, predictable, and easy to consume. While modern frameworks like **FastAPI** are fantastic for rapidly building APIs and automatically generating OpenAPI documentation, their default schema generation doesn't always align perfectly with Belgif's strict linting rules. In this article, we'll explore how to use the official Belgif OpenAPI linter via Docker and how to validate your FastAPI application against some potential errors. More importantly, we'll walk through practical, step-by-step solutions to resolve the most common linting errors caused by FastAPI's default behaviors, ensuring your API gets a clean bill of health. *Before we dive in, if you are looking for general advice on designing robust APIs, check out my previous article:* [API REST - How to write good APIs](/blog/php-api-tips). **Quick Jump:** [Seeing the Linter Run](#seeing-the-linter-run) | [Installation β€” Create a dummy application](#installation--create-a-dummy-application) ## Seeing the Linter Run Once the `belgif-lint` container is in place (covered under Installation below), checking an `openapi.json` file is two commands: ```bash $ curl -s http://localhost:8000/openapi.json > openapi.json ``` ```bash $ docker compose run --rm belgif-lint ``` It'll produce something like this: ```txt [INFO] Scanning for projects... [INFO] [INFO] ------------------< org.apache.maven:standalone-pom >------------------- [INFO] Building Maven Stub Project (No POM) 1 [INFO] --------------------------------[ pom ]--------------------------------- [INFO] [INFO] --- belgif-rest-guide-validator:3.1.0:validate-openapi (default-cli) @ standalone-pom --- [INFO] Validating following files:[/app/openapi.json] [INFO] Found kmodule: jar:file:/root/.m2/repository/io/github/belgif/rest/guide/validator/belgif-rest-guide-validator-rules/3.1.0/belgif-rest-guide-validator-rules-3.1.0.jar!/META-INF/kmodule.xml [INFO] Creating KieModule for artifact io.github.belgif.rest.guide.validator:belgif-rest-guide-validator-rules:3.1.0 [INFO] Artifact io.github.belgif.rest.guide.validator:belgif-rest-guide-validator-rules:3.1.0 has executable model [INFO] Start creation of KieBase: defaultKieBase [...] OpenApi validation summary: 30 violations and 0 ignored violations. [MANDATORY] [err-problem] Each error response of each operation SHOULD have a media type "application/problem+json" 2 occurrences: openapi.json ln 1 #/paths/items/{item_id}/get/responses/422 -- [Operation: GET /items/{item_id}] openapi.json ln 1 #/paths/items//get/responses/422 -- [Operation: GET /items/] [MANDATORY] [evo-object] In a request or response body, if any, you MUST always return a JSON object (and not e.g. an array) as a top level data structure to support future extensibility. 5 occurrences: openapi.json ln 1 #/paths//get/responses/200/content/application/json openapi.json ln 1 #/paths/items//get/responses/200/content/application/json openapi.json ln 1 #/paths/items/{item_id}/get/responses/200/content/application/json openapi.json ln 1 #/paths/items/{item_id}/get/responses/422/content/application/json openapi.json ln 1 #/paths/items//get/responses/422/content/application/json [MANDATORY] [oas-comp] Component names SHOULD use UpperCamelCase notation. For abbreviations as well, all letters except the first one should be lowercased. 1 occurrence: openapi.json ln 1 #/components/schemas/HTTPValidationError [MANDATORY] [oas-contra] OpenAPI 3.1 improves upon OpenAPI 3.0, but to avoid interoperability problems it SHOULD NOT be used yet because it is not yet widely supported by most tooling. 1 occurrence: # [MANDATORY] [oas-descr] The title property of a Schema MUST NOT be used. 10 occurrences: openapi.json ln 1 #/paths/items//get/parameters/0/schema openapi.json ln 1 #/components/schemas/ValidationError/properties/msg openapi.json ln 1 #/components/schemas/HTTPValidationError openapi.json ln 1 #/components/schemas/ValidationError/properties/type openapi.json ln 1 #/paths/items/{item_id}/get/parameters/1/schema openapi.json ln 1 #/components/schemas/ValidationError/properties/loc openapi.json ln 1 #/paths/items/{item_id}/get/parameters/0/schema openapi.json ln 1 #/components/schemas/ValidationError openapi.json ln 1 #/paths/items//get/parameters/1/schema openapi.json ln 1 #/components/schemas/HTTPValidationError/properties/detail [MANDATORY] [openapi-opid] A unique operationId MUST be specified on each operation. It SHOULD have a lowerCamelCase value following common programming naming conventions for method (function) names. 3 occurrences: openapi.json ln 1 #/paths/items//get -- OperationId not lowerCamelCase: read_items_items__get openapi.json ln 1 #/paths//get -- OperationId not lowerCamelCase: read_root__get openapi.json ln 1 #/paths/items/{item_id}/get -- OperationId not lowerCamelCase: read_item_items__item_id__get [MANDATORY] [path-param] Path parameters SHOULD use lowerCamelCase notation. 1 occurrence: openapi.json ln 1 #/paths/items/{item_id}/get/parameters/0 [MANDATORY] [prb-defaul] A default Problem response SHOULD be added to each operation with media type "application/problem+json" 3 occurrences: openapi.json ln 1 #/paths/items/{item_id}/get -- [Operation: GET /items/{item_id}] openapi.json ln 1 #/paths/items//get -- [Operation: GET /items/] openapi.json ln 1 #/paths//get -- [Operation: GET /] [MANDATORY] [uri-format] The server url should follow the format [/pathPrefix]/apiName/vMajorVersion optionally prefixed by https://hostname[:port] or http://localhost[:port]. The apiName must consist of one or more path segments in lowerCamelCase. 1 occurrence: #/servers/0 [MANDATORY] [uri-notat] Trailing slashes MUST NOT be used. 2 occurrences: openapi.json ln 1 #/paths/ openapi.json ln 1 #/paths/items/ [RECOMMENDED] [hlth-res] Each REST API SHOULD expose a GET /health operation which returns the availability of the API’s functionality for its clients. 1 occurrence: openapi.json ln 1 #/paths [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ [...] ``` > **Please refer to the official site:** > > From now on, please refer to to learn how to manage errors reported by the tool. **Bonus - Get rid of Belgif intern warnings** While running the Belgif linter, you may encounter a lot of warnings like below: **warnings.txt** ```txt [WARNING] In an eval expression [ApiFunctions.existsPathWithPathParamAfter($path.getIdentifier(), parserResult)] : '$path' comes from previous pattern 'PathDefinition'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [ApiFunctions.isMediaTypeIncluded($contentType, Set.of("application/json"))] : '$contentType' comes from previous pattern 'String'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [isObjectType($responseSchema, parserResult)] : '$responseSchema' comes from previous pattern 'Schema'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [!hasItemsArray($responseSchema, parserResult)] : '$responseSchema' comes from previous pattern 'Schema'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [ApiFunctions.existsPathWithPathParamAfter($path.getIdentifier(), parserResult)] : '$path' comes from previous pattern 'PathDefinition'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [ApiFunctions.isMediaTypeIncluded($contentType, Set.of("application/json"))] : '$contentType' comes from previous pattern 'String'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [isObjectType($responseSchema, parserResult)] : '$responseSchema' comes from previous pattern 'Schema'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [hasItemsArray($responseSchema, parserResult)] : '$responseSchema' comes from previous pattern 'Schema'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [!hasCollectionResponse($responseSchema, parserResult)] : '$responseSchema' comes from previous pattern 'Schema'. 'parserResult' comes from previous pattern 'Parser$ParserResult'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] In an eval expression [$schema != null] : '$schema' comes from previous pattern 'Schema'. Consider placing the constraint in the pattern and removing the eval if possible, as eval is not performance-efficient. [WARNING] More eval warnings will be suppressed... [WARNING] No correct line number found for: /servers/0 ``` The warnings are about the linter itself (in fact, comes from a tool called `Drools`) and thus **they have nothing to do with your code**. It's just visual pollution for us; the only thing we can do is to hide them. Look at the new file below: **compose.yaml** ```yaml services: app: build: . ports: - "8000:80" volumes: - ./main.py:/code/main.py # Belgif Validator Service belgif-lint: image: maven:3.9.9-eclipse-temurin-17-alpine volumes: - .:/app:ro - maven-cache:/root/.m2 working_dir: /app # 1. We override the entrypoint to use a shell (required for pipes) entrypoint: ["/bin/sh", "-c"] # 2. We use 'set +e' to not close the terminal if an error is met # Use style.color=always to keep ANSI colors while piping output to grep # Use 2>&1 to merge STDERR and STDOUT so line ordering is kept during the pipe command: > "set +e && mvn \ io.github.belgif.rest.guide.validator:belgif-rest-guide-validator-maven-plugin:3.1.0:validate-openapi \ -Dstyle.color=always \ -Drest-guide-validator.files=openapi.json \ -Drest-guide-validator.outputTypes=CONSOLE \ 2>&1 \ | grep -v 'eval expression' \ | grep -v 'More eval warnings will be suppressed' \ | grep -v 'comes from previous pattern' \ | grep -v 'No correct line number'" volumes: maven-cache: ``` In short, we'll run a custom command where we'll collect both STDERR and STDOUT in just one output stream then we'll run a few `grep` commands to purge specific messages from the output (the ones we can't solve). ## Belgif standards [Belgif REST standards](https://github.com/belgif/rest-guide-validator) are defined in and *is a collaborative effort by several Belgian government institutions, originally under the G-Cloud umbrella, before moving to Belgif, the Belgian Interoperability Framework. Its goal is to improve compatibility between RESTful services offered by government institutions or any other organization adopting these guidelines.* If you want to check if your API is compliant, you can use the [belgif-rest-guide-validator](https://github.com/belgif/rest-guide-validator) as documented in the [Tools](https://www.belgif.be/specification/rest/api-guide/#openapi-tools) section. ## Installation β€” Create a dummy application If you don't have one yet, just click on the `Generate install script` below and paste the CLI into a terminal then press the `Enter` key to create the project structure in a `/tmp/fastapi` folder on your disk. **Project: /tmp/fastapi** > **Note:** > > Now, please run 'docker compose up --build -d' to > create your API website. Wait a few and open your browser, surf to > to open your site and see your API OpenData documentation. **main.py** ```python from fastapi import FastAPI from typing import Union app = FastAPI() @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: Union[str, None] = None): return {"item_id": item_id, "q": q} @app.get("/items/") def read_items(skip: int = 0, limit: int = 10): return {"skip": skip, "limit": limit} ``` **compose.yaml** ```yaml services: app: build: . ports: - "8000:80" volumes: - ./main.py:/code/main.py ``` **Dockerfile** ```docker # Start from an official Python image. FROM python:3.11-slim # Set the working directory in the container. WORKDIR /code # Copy the requirements file and install dependencies. # We'll install fastapi and uvicorn (the server). RUN pip install --no-cache-dir 'fastapi[all]' # Copy the application code into the container. COPY ./main.py /code/main.py # Expose the port the app runs on. EXPOSE 80 # Command to run the application. CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] ``` The `main.py` code defines: * A root endpoint `/` that returns a simple JSON. * An endpoint `/items/{item_id}` that accepts an integer `item_id` as a path parameter and an optional string query parameter `q`. * An endpoint `/items/` that accepts `skip` and `limit` query parameters for pagination. To run it, simply start `docker compose up --build -d` in your terminal: ```bash $ docker compose up --build -d ``` Then surf to `http://localhost:`8000`/docs` to see your application running. You can test it using `curl` or your browser: 1. **Root endpoint:** ```bash $ curl http://localhost:8000 ``` You should see: `{"Hello":"World"}` 2. **Item with path and query parameter:** ```bash $ curl "http://localhost:8000/items/5?q=somequery" ``` You should see: `{"item_id":5,"q":"somequery"}` 3. **Items with pagination:** ```bash $ curl "http://localhost:8000/items/?skip=0&limit=20" ``` You should see: `{"skip":0,"limit":20}` ## Adding the Belgif Linter An easy way to do this is by using a Docker container. In the code sample below, take a look at the newer version of `compose.yaml`, we've added a new service called `belgif-lint` based on `maven`. **Project: /tmp/fastapi** > **Note:** > > Now, please run 'docker compose up --build -d' to > create your API website. Wait a few and open your browser, surf to > to open your site. **main.py** ```python from fastapi import FastAPI from typing import Union app = FastAPI() @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: Union[str, None] = None): return {"item_id": item_id, "q": q} @app.get("/items/") def read_items(skip: int = 0, limit: int = 10): return {"skip": skip, "limit": limit} ``` **compose.yaml** ```yaml services: app: build: . ports: - "8000:80" volumes: - ./main.py:/code/main.py # Belgif Validator Service belgif-lint: image: maven:3.9.9-eclipse-temurin-17-alpine volumes: - .:/app:ro - maven-cache:/root/.m2 working_dir: /app command: > mvn io.github.belgif.rest.guide.validator:belgif-rest-guide-validator-maven-plugin:3.1.0:validate-openapi -Drest-guide-validator.files=openapi.json -Drest-guide-validator.outputTypes=CONSOLE volumes: maven-cache: ``` **Dockerfile** ```docker # Start from an official Python image. FROM python:3.11-slim # Set the working directory in the container. WORKDIR /code # Copy the requirements file and install dependencies. # We'll install fastapi and uvicorn (the server). RUN pip install --no-cache-dir 'fastapi[all]' # Copy the application code into the container. COPY ./main.py /code/main.py # Expose the port the app runs on. EXPOSE 80 # Command to run the application. CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] ``` Once you've fired `docker compose up --build -d` again to create the `belgif-lint` container, you're ready to run the check shown at the top of this article β€” you just need an `openapi.json` file on disk first, which is exactly what the first command up there produces. ## FastAPI tips > **My own experience:** > > When integrating belgif into a FastAPI project, I encountered several linting errors that required manual troubleshooting. The guide below outlines the solution I developed to resolve these issues. **Please note that this approach is based on my personal findings and may differ from standard best practices.** ### oas-tags - Managing tags You'll get the `[MANDATORY] [oas-tags] Each tag used on an operation SHOULD also be declared in the top level tags list of the OpenAPI document, with an optional description.` error when you're using one or more tags in your endpoints and if these tags are not declared. To solve this, you've to use the `openapi_tags` attribute like illustrated below: **tags.py** ```python # Define the metadata for your tags tags_metadata: list[dict[str, str]] = [ { "name": "Retrieving data", "description": "Operations related to fetching and querying data.", }, { "name": "Healthcheck", "description": "Endpoints to monitor the health status of the application.", }, ] app = FastAPI( # ... openapi_tags=tags_metadata, # belgif [oas-tags] # ... ) ``` ### oas-contra - Don't use OpenAPI 3.1 yet If you get `[MANDATORY] [oas-contra] OpenAPI 3.1 improves upon OpenAPI 3.0, but to avoid interoperability problems it SHOULD NOT be used yet because it is not yet widely supported by most tooling.`, you can address this error by downgrading the OpenAPI version used by FastAPI like this: ```python app = FastAPI( # ... openapi_version="3.0.2", # belgif [oas-contra] ) ``` ### oas-descr - Title property has to be removed You'll get the `[MANDATORY] [oas-descr] The title property of a Schema MUST NOT be used.` when FastAPI will generate both a `title` and a `description` property for your objects. The solution is to add a helper function and remove the `title` (since `description` is always generated): Partial code: ```python if "components" in openapi_schema and "schemas" in openapi_schema["components"]: for schema in openapi_schema["components"]["schemas"].values(): _remove_titles(schema) ``` See the helper provided later on in the post. ### oas-comp - Component names SHOULD use UpperCamelCase notation If you get the `[MANDATORY] [oas-comp] Component names SHOULD use UpperCamelCase notation. For abbreviations as well, all letters except the first one should be lowercased.` error, you'll need to add a rename function to update e.g. `HTTPValidationError` to `HttpValidationError`. It can be done f.i. like this (partial code): ```python def _fix_schema_names(openapi_schema: dict[str, Any]) -> None: """ Renames schemas that violate Belgif's naming conventions. Specifically targets 'HTTPValidationError' (abbreviations must be camel-cased like 'Http', not 'HTTP'). """ components: Any = openapi_schema.get("components", {}) schemas: Any = components.get("schemas", {}) # Fix: HTTPValidationError -> HttpValidationError if "HTTPValidationError" in schemas: # 1. Move the definition to the new key schemas["HttpValidationError"] = schemas.pop("HTTPValidationError") # 2. Update all references ($ref) in the entire document to point to the new key _update_refs( openapi_schema, "#/components/schemas/HTTPValidationError", "#/components/schemas/HttpValidationError" ) ``` See the helper provided later on in the post. ### openapi-opid - lowerCamelCase for operationId The message `[MANDATORY] [openapi-opid] A unique operationId MUST be specified on each operation. It SHOULD have a lowerCamelCase value following common programming naming conventions for method (function) names.` notifies you about the use of a wrong syntax. Partial code: ```python def _fix_operation_ids(openapi_schema: dict[str, Any]) -> None: """ Iterates over all paths and converts snake_case operationIds to camelCase. """ paths: Any = openapi_schema.get("paths", {}) for path_item in paths.values(): for operation in path_item.values(): if isinstance(operation, dict) and "operationId" in operation: old_id: str = cast(str, operation["operationId"]) operation["operationId"] = _to_camel_case(old_id) ``` ### The openapi.py helper The helper provided below can help you to solve `oas-descr` and `oas-comp` errors. The `main.py` file illustrates how to call the helper. **main.py** ```python from helpers.openapi import configure_custom_openapi # Your code... app = FastAPI( # ... ) # Call the helper and pass the FastAPI object configure_custom_openapi(app) ``` **helpers/openapi.py** ```python """ OpenAPI Configuration Helper. This module isolates the logic required to customize the generated OpenAPI (Swagger) schema (i.e. the "openapi.json" file). It specifically handles: 1. Removal of redundant 'title' fields (Rule: oas-descr). 2. Renaming schemas to strict CamelCase (e.g. HTTPValidationError -> HttpValidationError). 3. Converting Operation IDs to camelCase (Rule: openapi-opid). This allows 'main.py' to remain clean while satisfying strict linters like Belgif. """ import re from typing import Any, cast from fastapi import FastAPI from fastapi.openapi.utils import get_openapi # Define a recursive type for JSON schemas to fix strict typing issues. JsonNode = dict[str, Any] | list[Any] | Any def _remove_titles(schema: Any) -> None: """ Recursively removes the 'title' key from a JSON-like schema dictionary. """ if isinstance(schema, dict): # Explicit cast to silence linter warnings about unknown keys s_dict: dict[str, Any] = cast(dict[str, Any], schema) s_dict.pop("title", None) for value in s_dict.values(): _remove_titles(value) elif isinstance(schema, list): for item in schema: _remove_titles(item) def _to_camel_case(snake_str: str) -> str: """Converts snake_case to lowerCamelCase.""" components: list[str] = snake_str.split("_") # We capitalize the first letter of each component except the first one return components[0] + "".join(x.title() for x in components[1:]) def _update_refs(item: Any, old_ref: str, new_ref: str) -> None: """ Recursively updates $ref strings in the schema. Used when a schema component is renamed. """ if isinstance(item, dict): s_dict: dict[str, Any] = cast(dict[str, Any], item) # If this node is a reference to the old name, update it if s_dict.get("$ref") == old_ref: s_dict["$ref"] = new_ref return for value in s_dict.values(): _update_refs(value, old_ref, new_ref) elif isinstance(item, list): for sub_item in item: _update_refs(sub_item, old_ref, new_ref) def _fix_schema_names(openapi_schema: dict[str, Any]) -> None: """ Renames schemas that violate Belgif's naming conventions. Specifically targets 'HTTPValidationError' (abbreviations must be camel-cased like 'Http', not 'HTTP'). """ components: Any = openapi_schema.get("components", {}) schemas: Any = components.get("schemas", {}) # Fix: HTTPValidationError -> HttpValidationError if "HTTPValidationError" in schemas: # 1. Move the definition to the new key schemas["HttpValidationError"] = schemas.pop("HTTPValidationError") # 2. Update all references ($ref) in the entire document to point to the new key _update_refs( openapi_schema, "#/components/schemas/HTTPValidationError", "#/components/schemas/HttpValidationError" ) def _fix_operation_ids(openapi_schema: dict[str, Any]) -> None: """ Iterates over all paths and converts snake_case operationIds to camelCase. """ paths: Any = openapi_schema.get("paths", {}) for path_item in paths.values(): for operation in path_item.values(): if isinstance(operation, dict) and "operationId" in operation: old_id: str = cast(str, operation["operationId"]) operation["operationId"] = _to_camel_case(old_id) def configure_custom_openapi(app: FastAPI) -> None: """ Overrides the default OpenAPI generator to apply Belgif compliance fixes. Args: app: The FastAPI application instance to configure. """ def custom_openapi() -> dict[str, Any]: if app.openapi_schema: return app.openapi_schema # 1. Generate the default OpenAPI schema openapi_schema: dict[str, Any] = get_openapi( title=app.title, version=app.version, description=app.description, openapi_version=app.openapi_version, routes=app.routes, tags=app.openapi_tags, ) # 2. Fix 1: Remove Titles (Clutter reduction) (belgif [oas-descr]) if "components" in openapi_schema and "schemas" in openapi_schema["components"]: for schema in openapi_schema["components"]["schemas"].values(): _remove_titles(schema) # 3. Fix 2: Rename Schemas (Naming conventions) (belgif [oas-comp]) _fix_schema_names(openapi_schema) # 4. Fix 3: Operation IDs (snake_case -> camelCase) (belgif [openapi-opid]) _fix_operation_ids(openapi_schema) app.openapi_schema = openapi_schema return app.openapi_schema # Assign the custom function to the app instance app.openapi = custom_openapi # type: ignore[method-assign] ``` ## Conclusion FastAPI gets you a working OpenAPI schema for free, but "working" and "Belgif-compliant" are two different bars β€” the linter above is what closes that gap, and the fixes in this article cover the specific violations FastAPI's defaults trigger most often (tags, the OpenAPI version, `title` properties, naming conventions). Run `belgif-lint` before you ship, not after a reviewer flags it. If you're still shaping the API itself rather than validating it, [API REST - How to write good APIs](/blog/php-api-tips) is the article to read first. ---