mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-27 04:19:43 +00:00
Add --required-version
(#1376)
This commit is contained in:
parent
19121219fb
commit
8b72f55a09
15 changed files with 183 additions and 17 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
@ -1902,6 +1902,7 @@ dependencies = [
|
|||
"rustpython-common",
|
||||
"rustpython-parser",
|
||||
"schemars",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shellexpand",
|
||||
|
@ -2122,9 +2123,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.14"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4"
|
||||
checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
|
|
|
@ -50,6 +50,7 @@ rustpython-ast = { features = ["unparse"], git = "https://github.com/RustPython/
|
|||
rustpython-common = { git = "https://github.com/RustPython/RustPython.git", rev = "1b6cb170e925a43d605b3fed9f6b878e63e47744" }
|
||||
rustpython-parser = { features = ["lalrpop"], git = "https://github.com/RustPython/RustPython.git", rev = "1b6cb170e925a43d605b3fed9f6b878e63e47744" }
|
||||
schemars = { version = "0.8.11" }
|
||||
semver = { version = "1.0.16" }
|
||||
serde = { version = "1.0.147", features = ["derive"] }
|
||||
serde_json = { version = "1.0.87" }
|
||||
shellexpand = { version = "3.0.0" }
|
||||
|
|
19
README.md
19
README.md
|
@ -1965,6 +1965,25 @@ when considering any matching files.
|
|||
|
||||
---
|
||||
|
||||
#### [`required-version`](#required-version)
|
||||
|
||||
Require a specific version of Ruff to be running (useful for unifying
|
||||
results across many environments, e.g., with a `pyproject.toml`
|
||||
file).
|
||||
|
||||
**Default value**: `None`
|
||||
|
||||
**Type**: `String`
|
||||
|
||||
**Example usage**:
|
||||
|
||||
```toml
|
||||
[tool.ruff]
|
||||
required-version = "0.0.193"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### [`respect-gitignore`](#respect-gitignore)
|
||||
|
||||
Whether to automatically exclude files that are ignored by `.ignore`,
|
||||
|
|
|
@ -303,6 +303,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
@ -359,6 +360,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: Some(100),
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
@ -415,6 +417,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: Some(100),
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
@ -471,6 +474,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
@ -527,6 +531,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
@ -591,6 +596,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::D100,
|
||||
|
@ -683,6 +689,7 @@ mod tests {
|
|||
ignore_init_module_imports: None,
|
||||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
required_version: None,
|
||||
respect_gitignore: None,
|
||||
select: Some(vec![
|
||||
CheckCodePrefix::E,
|
||||
|
|
|
@ -34,7 +34,11 @@ bindings = "bin"
|
|||
strip = true
|
||||
|
||||
[tool.ruff]
|
||||
#required-version = "0.0.192"
|
||||
|
||||
[tool.ruff.isort]
|
||||
force-wrap-aliases = true
|
||||
combine-as-imports = true
|
||||
|
||||
[tool.black]
|
||||
required-version = "22.12.1"
|
||||
|
|
|
@ -288,6 +288,17 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"required-version": {
|
||||
"description": "Require a specific version of Ruff to be running (useful for unifying results across many environments, e.g., with a `pyproject.toml` file).",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Version"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"respect-gitignore": {
|
||||
"description": "Whether to automatically exclude files that are ignored by `.ignore`, `.gitignore`, `.git/info/exclude`, and global `gitignore` files. Enabled by default.",
|
||||
"type": [
|
||||
|
@ -1196,6 +1207,9 @@
|
|||
"parents",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"Version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,14 +38,8 @@ pub fn run(
|
|||
let duration = start.elapsed();
|
||||
debug!("Identified files to lint in: {:?}", duration);
|
||||
|
||||
// Discover the package root for each Python file.
|
||||
let package_roots = packages::detect_package_roots(
|
||||
&paths
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(ignore::DirEntry::path)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
// Validate the `Settings` and return any errors.
|
||||
resolver.validate(pyproject_strategy)?;
|
||||
|
||||
// Initialize the cache.
|
||||
if matches!(cache, flags::Cache::Enabled) {
|
||||
|
@ -71,6 +65,15 @@ pub fn run(
|
|||
}
|
||||
};
|
||||
|
||||
// Discover the package root for each Python file.
|
||||
let package_roots = packages::detect_package_roots(
|
||||
&paths
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(ignore::DirEntry::path)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
let start = Instant::now();
|
||||
let mut diagnostics: Diagnostics = par_iter(&paths)
|
||||
.map(|entry| {
|
||||
|
@ -176,6 +179,9 @@ pub fn add_noqa(
|
|||
let duration = start.elapsed();
|
||||
debug!("Identified files to lint in: {:?}", duration);
|
||||
|
||||
// Validate the `Settings` and return any errors.
|
||||
resolver.validate(pyproject_strategy)?;
|
||||
|
||||
let start = Instant::now();
|
||||
let modifications: usize = par_iter(&paths)
|
||||
.flatten()
|
||||
|
@ -212,6 +218,9 @@ pub fn autoformat(
|
|||
let duration = start.elapsed();
|
||||
debug!("Identified files to lint in: {:?}", duration);
|
||||
|
||||
// Validate the `Settings` and return any errors.
|
||||
resolver.validate(pyproject_strategy)?;
|
||||
|
||||
let start = Instant::now();
|
||||
let modifications = par_iter(&paths)
|
||||
.flatten()
|
||||
|
@ -245,6 +254,9 @@ pub fn show_settings(
|
|||
let (paths, resolver) =
|
||||
resolver::python_files_in_path(files, pyproject_strategy, file_strategy, overrides)?;
|
||||
|
||||
// Validate the `Settings` and return any errors.
|
||||
resolver.validate(pyproject_strategy)?;
|
||||
|
||||
// Print the list of files.
|
||||
let Some(entry) = paths
|
||||
.iter()
|
||||
|
@ -268,9 +280,12 @@ pub fn show_files(
|
|||
overrides: &Overrides,
|
||||
) -> Result<()> {
|
||||
// Collect all files in the hierarchy.
|
||||
let (paths, _resolver) =
|
||||
let (paths, resolver) =
|
||||
resolver::python_files_in_path(files, pyproject_strategy, file_strategy, overrides)?;
|
||||
|
||||
// Validate the `Settings` and return any errors.
|
||||
resolver.validate(pyproject_strategy)?;
|
||||
|
||||
// Print the list of files.
|
||||
for entry in paths
|
||||
.iter()
|
||||
|
|
|
@ -59,6 +59,9 @@ pub(crate) fn check_path(
|
|||
autofix: flags::Autofix,
|
||||
noqa: flags::Noqa,
|
||||
) -> Result<Vec<Check>> {
|
||||
// Validate the `Settings` and return any errors.
|
||||
settings.validate()?;
|
||||
|
||||
// Aggregate all checks.
|
||||
let mut checks: Vec<Check> = vec![];
|
||||
|
||||
|
@ -175,6 +178,9 @@ pub fn lint_path(
|
|||
cache: flags::Cache,
|
||||
autofix: fixer::Mode,
|
||||
) -> Result<Diagnostics> {
|
||||
// Validate the `Settings` and return any errors.
|
||||
settings.validate()?;
|
||||
|
||||
let metadata = path.metadata()?;
|
||||
|
||||
// Check the cache.
|
||||
|
@ -202,6 +208,9 @@ pub fn lint_path(
|
|||
|
||||
/// Add any missing `#noqa` pragmas to the source code at the given `Path`.
|
||||
pub fn add_noqa_to_path(path: &Path, settings: &Settings) -> Result<usize> {
|
||||
// Validate the `Settings` and return any errors.
|
||||
settings.validate()?;
|
||||
|
||||
// Read the file from disk.
|
||||
let contents = fs::read_file(path)?;
|
||||
|
||||
|
@ -241,7 +250,10 @@ pub fn add_noqa_to_path(path: &Path, settings: &Settings) -> Result<usize> {
|
|||
}
|
||||
|
||||
/// Apply autoformatting to the source code at the given `Path`.
|
||||
pub fn autoformat_path(path: &Path, _settings: &Settings) -> Result<()> {
|
||||
pub fn autoformat_path(path: &Path, settings: &Settings) -> Result<()> {
|
||||
// Validate the `Settings` and return any errors.
|
||||
settings.validate()?;
|
||||
|
||||
// Read the file from disk.
|
||||
let contents = fs::read_file(path)?;
|
||||
|
||||
|
@ -266,6 +278,9 @@ pub fn lint_stdin(
|
|||
settings: &Settings,
|
||||
autofix: fixer::Mode,
|
||||
) -> Result<Diagnostics> {
|
||||
// Validate the `Settings` and return any errors.
|
||||
settings.validate()?;
|
||||
|
||||
// Read the file from disk.
|
||||
let contents = stdin.to_string();
|
||||
|
||||
|
|
|
@ -100,6 +100,12 @@ fn inner_main() -> Result<ExitCode> {
|
|||
cli.stdin_filename.as_deref(),
|
||||
)?;
|
||||
|
||||
// Validate the `Settings` and return any errors.
|
||||
match &pyproject_strategy {
|
||||
PyprojectDiscovery::Fixed(settings) => settings.validate()?,
|
||||
PyprojectDiscovery::Hierarchical(settings) => settings.validate()?,
|
||||
};
|
||||
|
||||
// Extract options that are included in `Settings`, but only apply at the top
|
||||
// level.
|
||||
let file_strategy = FileDiscovery {
|
||||
|
|
|
@ -91,6 +91,26 @@ impl Resolver {
|
|||
pub fn iter(&self) -> impl Iterator<Item = &Settings> {
|
||||
self.settings.values()
|
||||
}
|
||||
|
||||
/// Validate all resolved `Settings` in this `Resolver`.
|
||||
pub fn validate(&self, strategy: &PyprojectDiscovery) -> Result<()> {
|
||||
// TODO(charlie): This risks false positives (but not false negatives), since
|
||||
// some of the `Settings` in the path may ultimately be unused (or, e.g., they
|
||||
// could have their `required_version` overridden by other `Settings` in
|
||||
// the path). It'd be preferable to validate once we've determined the
|
||||
// `Settings` for each path, but that's more expensive.
|
||||
match &strategy {
|
||||
PyprojectDiscovery::Fixed(settings) => {
|
||||
settings.validate()?;
|
||||
}
|
||||
PyprojectDiscovery::Hierarchical(default) => {
|
||||
for settings in std::iter::once(default).chain(self.iter()) {
|
||||
settings.validate()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively resolve a `Configuration` from a `pyproject.toml` file at the
|
||||
|
|
|
@ -16,7 +16,9 @@ use crate::checks_gen::CheckCodePrefix;
|
|||
use crate::cli::{collect_per_file_ignores, Overrides};
|
||||
use crate::settings::options::Options;
|
||||
use crate::settings::pyproject::load_options;
|
||||
use crate::settings::types::{FilePattern, PerFileIgnore, PythonVersion, SerializationFormat};
|
||||
use crate::settings::types::{
|
||||
FilePattern, PerFileIgnore, PythonVersion, SerializationFormat, Version,
|
||||
};
|
||||
use crate::{
|
||||
flake8_annotations, flake8_bugbear, flake8_errmsg, flake8_import_conventions, flake8_quotes,
|
||||
flake8_tidy_imports, flake8_unused_arguments, fs, isort, mccabe, pep8_naming, pyupgrade,
|
||||
|
@ -41,6 +43,7 @@ pub struct Configuration {
|
|||
pub ignore_init_module_imports: Option<bool>,
|
||||
pub line_length: Option<usize>,
|
||||
pub per_file_ignores: Option<Vec<PerFileIgnore>>,
|
||||
pub required_version: Option<Version>,
|
||||
pub respect_gitignore: Option<bool>,
|
||||
pub select: Option<Vec<CheckCodePrefix>>,
|
||||
pub show_source: Option<bool>,
|
||||
|
@ -124,6 +127,7 @@ impl Configuration {
|
|||
})
|
||||
.collect()
|
||||
}),
|
||||
required_version: options.required_version,
|
||||
respect_gitignore: options.respect_gitignore,
|
||||
select: options.select,
|
||||
show_source: options.show_source,
|
||||
|
@ -162,7 +166,6 @@ impl Configuration {
|
|||
allowed_confusables: self.allowed_confusables.or(config.allowed_confusables),
|
||||
dummy_variable_rgx: self.dummy_variable_rgx.or(config.dummy_variable_rgx),
|
||||
exclude: self.exclude.or(config.exclude),
|
||||
respect_gitignore: self.respect_gitignore.or(config.respect_gitignore),
|
||||
extend: self.extend.or(config.extend),
|
||||
extend_exclude: config
|
||||
.extend_exclude
|
||||
|
@ -191,6 +194,8 @@ impl Configuration {
|
|||
.or(config.ignore_init_module_imports),
|
||||
line_length: self.line_length.or(config.line_length),
|
||||
per_file_ignores: self.per_file_ignores.or(config.per_file_ignores),
|
||||
required_version: self.required_version.or(config.required_version),
|
||||
respect_gitignore: self.respect_gitignore.or(config.respect_gitignore),
|
||||
select: self.select.or(config.select),
|
||||
show_source: self.show_source.or(config.show_source),
|
||||
src: self.src.or(config.src),
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use globset::{Glob, GlobMatcher, GlobSet};
|
||||
use itertools::Itertools;
|
||||
use once_cell::sync::Lazy;
|
||||
|
@ -17,7 +17,9 @@ use crate::cache::cache_dir;
|
|||
use crate::checks::CheckCode;
|
||||
use crate::checks_gen::{CheckCodePrefix, SuffixLength, CATEGORIES};
|
||||
use crate::settings::configuration::Configuration;
|
||||
use crate::settings::types::{FilePattern, PerFileIgnore, PythonVersion, SerializationFormat};
|
||||
use crate::settings::types::{
|
||||
FilePattern, PerFileIgnore, PythonVersion, SerializationFormat, Version,
|
||||
};
|
||||
use crate::{
|
||||
flake8_annotations, flake8_bugbear, flake8_errmsg, flake8_import_conventions, flake8_quotes,
|
||||
flake8_tidy_imports, flake8_unused_arguments, isort, mccabe, pep8_naming, pyupgrade,
|
||||
|
@ -30,6 +32,8 @@ pub mod options_base;
|
|||
pub mod pyproject;
|
||||
pub mod types;
|
||||
|
||||
const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct Settings {
|
||||
|
@ -47,6 +51,7 @@ pub struct Settings {
|
|||
pub ignore_init_module_imports: bool,
|
||||
pub line_length: usize,
|
||||
pub per_file_ignores: Vec<(GlobMatcher, GlobMatcher, FxHashSet<CheckCode>)>,
|
||||
pub required_version: Option<Version>,
|
||||
pub respect_gitignore: bool,
|
||||
pub show_source: bool,
|
||||
pub src: Vec<PathBuf>,
|
||||
|
@ -139,6 +144,7 @@ impl Settings {
|
|||
config.per_file_ignores.unwrap_or_default(),
|
||||
)?,
|
||||
respect_gitignore: config.respect_gitignore.unwrap_or(true),
|
||||
required_version: config.required_version,
|
||||
src: config
|
||||
.src
|
||||
.unwrap_or_else(|| vec![project_root.to_path_buf()]),
|
||||
|
@ -211,6 +217,7 @@ impl Settings {
|
|||
ignore_init_module_imports: false,
|
||||
line_length: 88,
|
||||
per_file_ignores: vec![],
|
||||
required_version: None,
|
||||
respect_gitignore: true,
|
||||
show_source: false,
|
||||
src: vec![path_dedot::CWD.clone()],
|
||||
|
@ -246,6 +253,7 @@ impl Settings {
|
|||
ignore_init_module_imports: false,
|
||||
line_length: 88,
|
||||
per_file_ignores: vec![],
|
||||
required_version: None,
|
||||
respect_gitignore: true,
|
||||
show_source: false,
|
||||
src: vec![path_dedot::CWD.clone()],
|
||||
|
@ -264,6 +272,19 @@ impl Settings {
|
|||
pyupgrade: pyupgrade::settings::Settings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(required_version) = &self.required_version {
|
||||
if &**required_version != CARGO_PKG_VERSION {
|
||||
return Err(anyhow!(
|
||||
"Required version `{}` does not match the running version `{}`",
|
||||
&**required_version,
|
||||
CARGO_PKG_VERSION
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Settings {
|
||||
|
|
|
@ -6,7 +6,7 @@ use schemars::JsonSchema;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::checks_gen::CheckCodePrefix;
|
||||
use crate::settings::types::{PythonVersion, SerializationFormat};
|
||||
use crate::settings::types::{PythonVersion, SerializationFormat, Version};
|
||||
use crate::{
|
||||
flake8_annotations, flake8_bugbear, flake8_errmsg, flake8_import_conventions, flake8_quotes,
|
||||
flake8_tidy_imports, flake8_unused_arguments, isort, mccabe, pep8_naming, pyupgrade,
|
||||
|
@ -216,6 +216,17 @@ pub struct Options {
|
|||
/// The line length to use when enforcing long-lines violations (like
|
||||
/// `E501`).
|
||||
pub line_length: Option<usize>,
|
||||
#[option(
|
||||
default = "None",
|
||||
value_type = "String",
|
||||
example = r#"
|
||||
required-version = "0.0.193"
|
||||
"#
|
||||
)]
|
||||
/// Require a specific version of Ruff to be running (useful for unifying
|
||||
/// results across many environments, e.g., with a `pyproject.toml`
|
||||
/// file).
|
||||
pub required_version: Option<Version>,
|
||||
#[option(
|
||||
default = "true",
|
||||
value_type = "bool",
|
||||
|
|
|
@ -137,6 +137,7 @@ mod tests {
|
|||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
select: None,
|
||||
show_source: None,
|
||||
src: None,
|
||||
|
@ -187,6 +188,7 @@ line-length = 79
|
|||
line_length: Some(79),
|
||||
per_file_ignores: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
select: None,
|
||||
show_source: None,
|
||||
src: None,
|
||||
|
@ -237,6 +239,7 @@ exclude = ["foo.py"]
|
|||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
select: None,
|
||||
show_source: None,
|
||||
src: None,
|
||||
|
@ -287,6 +290,7 @@ select = ["E501"]
|
|||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
select: Some(vec![CheckCodePrefix::E501]),
|
||||
show_source: None,
|
||||
src: None,
|
||||
|
@ -338,6 +342,7 @@ ignore = ["E501"]
|
|||
line_length: None,
|
||||
per_file_ignores: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
select: None,
|
||||
show_source: None,
|
||||
src: None,
|
||||
|
@ -433,6 +438,7 @@ other-attribute = 1
|
|||
)])),
|
||||
dummy_variable_rgx: None,
|
||||
respect_gitignore: None,
|
||||
required_version: None,
|
||||
src: None,
|
||||
target_version: None,
|
||||
show_source: None,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::env;
|
||||
use std::hash::Hash;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
|
@ -165,3 +166,23 @@ impl Default for SerializationFormat {
|
|||
Self::Text
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct Version(String);
|
||||
|
||||
impl TryFrom<String> for Version {
|
||||
type Error = semver::Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
semver::Version::parse(&value).map(|_| Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Version {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue