Add uv check to run ty from uv (#19605)

## Summary

This is the minimal implementation for fetching and running `ty check`.
Currently it doesn't attempt to provide member selection information to
ty, specifying semantics that are essentially "this downloads and runs
ty in cwd, you can pass arbitrary flags to ty yourself".

This is therefore reasonably far from the potential vision of "check is
a monolithic linter command", but might still be worth landing to
iterate on, because most of the implementation is still what we want --
download ty, run sync, invoke ty.

## Test Plan

Zanie and I reviewed it and various tests were added. Not sweating the details too hard so we can iterate.
This commit is contained in:
Aria Desires
2026-05-29 13:44:06 -07:00
committed by GitHub
parent 40cfa8ed21
commit f69c1b274b
13 changed files with 1154 additions and 3 deletions
+155 -2
View File
@@ -35,6 +35,7 @@ use uv_redacted::DisplaySafeUrl;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Binary {
Ruff,
Ty,
Uv,
}
@@ -52,6 +53,12 @@ impl Binary {
]
.into_iter()
.collect(),
Self::Ty => [
VersionSpecifier::greater_than_equal_version(Version::new([0, 0])),
VersionSpecifier::less_than_version(Version::new([0, 1])),
]
.into_iter()
.collect(),
Self::Uv => VersionSpecifiers::empty(),
}
}
@@ -62,6 +69,7 @@ impl Binary {
pub fn name(&self) -> &'static str {
match self {
Self::Ruff => "ruff",
Self::Ty => "ty",
Self::Uv => "uv",
}
}
@@ -103,6 +111,18 @@ impl Binary {
}
Ok(urls)
}
Self::Ty => {
let suffix = format!("{version}/ty-{platform}.{}", format.extension());
let mirror_base = astral_mirror_base_url(astral_mirror_url);
let mirror = format!("{mirror_base}{TY_MIRROR_SUFFIX}{suffix}");
let mut urls = vec![parse_url(mirror)?];
// When using the default mirror, also fall back to GitHub.
if astral_mirror_url.is_none() {
let canonical = format!("{TY_GITHUB_URL_PREFIX}{suffix}");
urls.push(parse_url(canonical)?);
}
Ok(urls)
}
Self::Uv => {
let canonical = format!(
"{UV_GITHUB_URL_PREFIX}{version}/uv-{platform}.{}",
@@ -162,6 +182,18 @@ impl Binary {
}
Ok(vec![canonical_url])
}
Self::Ty => {
if let Some(suffix) = canonical_url.as_str().strip_prefix(TY_GITHUB_URL_PREFIX) {
let mirror_base = astral_mirror_base_url(astral_mirror_url);
let mirror = format!("{mirror_base}{TY_MIRROR_SUFFIX}{suffix}");
let mirror_url = parse_url(mirror)?;
if astral_mirror_url.is_some() {
return Ok(vec![mirror_url]);
}
return Ok(vec![mirror_url, canonical_url]);
}
Ok(vec![canonical_url])
}
Self::Uv => Ok(vec![canonical_url]),
}
}
@@ -248,12 +280,18 @@ impl fmt::Display for BinVersion {
/// The canonical GitHub URL prefix for Ruff releases.
const RUFF_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ruff/releases/download/";
/// The canonical GitHub URL prefix for ty releases.
const TY_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ty/releases/download/";
/// The canonical GitHub URL prefix for uv releases.
const UV_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/uv/releases/download/";
/// The suffix appended to the Astral mirror base for Ruff releases.
const RUFF_MIRROR_SUFFIX: &str = "/github/ruff/releases/download/";
/// The suffix appended to the Astral mirror base for ty releases.
const TY_MIRROR_SUFFIX: &str = "/github/ty/releases/download/";
/// The suffix appended to the Astral mirror base for the versions manifest.
const VERSIONS_MANIFEST_MIRROR_SUFFIX: &str = "/github/versions/main/v1";
@@ -1031,9 +1069,72 @@ mod tests {
assert_eq!(default_urls, empty_urls);
}
#[test]
fn test_ty_download_urls_custom_astral_mirror() {
let urls = Binary::Ty
.download_urls_with_astral_mirror(
&Version::new([0, 0, 1]),
"x86_64-unknown-linux-gnu",
ArchiveFormat::TarGz,
Some("https://nexus.example.com/repository/releases.astral.sh/"),
)
.expect("ty download URLs should be valid");
let urls = urls
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>();
assert_eq!(
urls,
vec![
"https://nexus.example.com/repository/releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz"
.to_string(),
]
);
}
#[test]
fn test_ty_download_urls_use_default_astral_mirror_then_github() {
let default_urls = Binary::Ty
.download_urls_with_astral_mirror(
&Version::new([0, 0, 1]),
"x86_64-unknown-linux-gnu",
ArchiveFormat::TarGz,
None,
)
.expect("ty download URLs should be valid");
let empty_urls = Binary::Ty
.download_urls_with_astral_mirror(
&Version::new([0, 0, 1]),
"x86_64-unknown-linux-gnu",
ArchiveFormat::TarGz,
Some(""),
)
.expect("ty download URLs should be valid");
assert_eq!(default_urls, empty_urls);
assert_eq!(
default_urls,
vec![
DisplaySafeUrl::parse(
"https://releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
)
.expect("default Astral mirror ty URL should be valid"),
DisplaySafeUrl::parse(
"https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
)
.expect("canonical ty URL should be valid"),
]
);
}
#[test]
fn test_manifest_urls_custom_astral_mirror() {
for (binary, filename) in [(Binary::Ruff, "ruff.ndjson"), (Binary::Uv, "uv.ndjson")] {
for (binary, filename) in [
(Binary::Ruff, "ruff.ndjson"),
(Binary::Ty, "ty.ndjson"),
(Binary::Uv, "uv.ndjson"),
] {
let urls = binary
.manifest_urls_with_astral_mirror(Some(
"https://nexus.example.com/repository/releases.astral.sh/",
@@ -1079,9 +1180,61 @@ mod tests {
);
}
#[test]
fn test_ty_mirror_urls_custom_astral_mirror() {
let canonical_url = DisplaySafeUrl::parse(
"https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
)
.expect("canonical ty URL should be valid");
let urls = Binary::Ty
.mirror_urls_with_astral_mirror(
canonical_url,
Some("https://nexus.example.com/repository/releases.astral.sh/"),
)
.expect("mirror URLs should be valid");
let urls = urls
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>();
assert_eq!(
urls,
vec![
"https://nexus.example.com/repository/releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz"
.to_string(),
]
);
}
#[test]
fn test_ty_mirror_urls_use_default_astral_mirror_then_github() {
let canonical_url = DisplaySafeUrl::parse(
"https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
)
.expect("canonical ty URL should be valid");
let default_urls = Binary::Ty
.mirror_urls_with_astral_mirror(canonical_url.clone(), None)
.expect("mirror URLs should be valid");
let empty_urls = Binary::Ty
.mirror_urls_with_astral_mirror(canonical_url.clone(), Some(""))
.expect("mirror URLs should be valid");
assert_eq!(default_urls, empty_urls);
assert_eq!(
default_urls,
vec![
DisplaySafeUrl::parse(
"https://releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
)
.expect("default Astral mirror ty URL should be valid"),
canonical_url,
]
);
}
#[test]
fn test_manifest_urls_empty_astral_mirror_uses_default() {
for binary in [Binary::Ruff, Binary::Uv] {
for binary in [Binary::Ruff, Binary::Ty, Binary::Uv] {
let default_urls = binary
.manifest_urls_with_astral_mirror(None)
.expect("manifest URLs should be valid");
+192
View File
@@ -1203,6 +1203,18 @@ pub enum ProjectCommand {
after_long_help = ""
)]
Format(FormatArgs),
/// Type check the project.
///
/// Type checks Python code using ty. By default, all Python files in the project
/// are checked. This command has the same behavior as running `ty check` in the project
/// root.
///
/// Additional arguments can be passed to ty after `--`.
#[command(
after_help = "Use `uv help check` for more details.",
after_long_help = ""
)]
Check(CheckArgs),
/// Audit the project's dependencies.
///
/// Dependencies are audited for known vulnerabilities, as well as 'adverse' statuses such as
@@ -5197,6 +5209,186 @@ pub struct FormatArgs {
pub show_version: bool,
}
#[derive(Args)]
pub struct CheckArgs {
/// Include optional dependencies from the specified extra name.
///
/// May be provided more than once.
///
/// When multiple extras or groups are specified that appear in `tool.uv.conflicts`, uv will
/// report an error.
///
/// Note that all optional dependencies are always included in the resolution; this option only
/// affects the selection of packages to install.
#[arg(
long,
conflicts_with = "all_extras",
conflicts_with = "only_group",
value_delimiter = ',',
value_parser = extra_name_with_clap_error,
value_hint = ValueHint::Other,
)]
pub extra: Option<Vec<ExtraName>>,
/// Include all optional dependencies.
///
/// When two or more extras are declared as conflicting in `tool.uv.conflicts`, using this flag
/// will always result in an error.
///
/// Note that all optional dependencies are always included in the resolution; this option only
/// affects the selection of packages to install.
#[arg(long, conflicts_with = "extra", conflicts_with = "only_group")]
pub all_extras: bool,
/// Exclude the specified optional dependencies, if `--all-extras` is supplied.
///
/// May be provided multiple times.
#[arg(long, value_hint = ValueHint::Other)]
pub no_extra: Vec<ExtraName>,
#[arg(long, overrides_with("all_extras"), hide = true)]
pub no_all_extras: bool,
/// Include the development dependency group [env: UV_DEV=]
///
/// This option is an alias for `--group dev`.
#[arg(long, overrides_with("no_dev"), hide = true, value_parser = clap::builder::BoolishValueParser::new())]
pub dev: bool,
/// Disable the development dependency group [env: UV_NO_DEV=]
///
/// This option is an alias of `--no-group dev`.
/// See `--no-default-groups` to disable all default groups instead.
#[arg(long, overrides_with("dev"), value_parser = clap::builder::BoolishValueParser::new())]
pub no_dev: bool,
/// Only include the development dependency group.
///
/// The project and its dependencies will be omitted.
///
/// This option is an alias for `--only-group dev`. Implies `--no-default-groups`.
#[arg(long, conflicts_with_all = ["group", "all_groups", "no_dev"])]
pub only_dev: bool,
/// Include dependencies from the specified dependency group.
///
/// When multiple extras or groups are specified that appear in
/// `tool.uv.conflicts`, uv will report an error.
///
/// May be provided multiple times.
#[arg(long, conflicts_with_all = ["only_group", "only_dev"], value_hint = ValueHint::Other)]
pub group: Vec<GroupName>,
/// Disable the specified dependency group [env: `UV_NO_GROUP`=]
///
/// This option always takes precedence over default groups,
/// `--all-groups`, and `--group`.
///
/// May be provided multiple times.
#[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other)]
pub no_group: Vec<GroupName>,
/// Ignore the default dependency groups.
///
/// uv includes the groups defined in `tool.uv.default-groups` by default.
/// This disables that option, however, specific groups can still be included with `--group`.
#[arg(long, env = EnvVars::UV_NO_DEFAULT_GROUPS, value_parser = clap::builder::BoolishValueParser::new())]
pub no_default_groups: bool,
/// Only include dependencies from the specified dependency group.
///
/// The project and its dependencies will be omitted.
///
/// May be provided multiple times. Implies `--no-default-groups`.
#[arg(long, conflicts_with_all = ["group", "dev", "all_groups"], value_hint = ValueHint::Other)]
pub only_group: Vec<GroupName>,
/// Include dependencies from all dependency groups.
///
/// `--no-group` can be used to exclude specific groups.
#[arg(long, conflicts_with_all = ["only_group", "only_dev"])]
pub all_groups: bool,
/// Assert that the `uv.lock` will remain unchanged [env: UV_LOCKED=]
///
/// Requires that the lockfile is up-to-date. If the lockfile is missing or needs to be updated,
/// uv will exit with an error.
#[arg(long, conflicts_with_all = ["frozen", "upgrade"])]
pub locked: bool,
/// Sync without updating the `uv.lock` file [env: UV_FROZEN=]
///
/// Instead of checking if the lockfile is up-to-date, uses the versions in the lockfile as the
/// source of truth. If the lockfile is missing, uv will exit with an error. If the
/// `pyproject.toml` includes changes to dependencies that have not been included in the
/// lockfile yet, they will not be present in the environment.
#[arg(long, conflicts_with_all = ["locked", "upgrade", "no_sources"])]
pub frozen: bool,
/// Avoid syncing the virtual environment [env: UV_NO_SYNC=]
///
/// Implies `--frozen`, as the project dependencies will be ignored (i.e., the lockfile will not
/// be updated, since the environment will not be synced regardless).
#[arg(long)]
pub no_sync: bool,
/// The Python interpreter to use for the project environment.
///
/// By default, the first interpreter that meets the project's
/// `requires-python` constraint is used.
///
/// See `uv python` for more details on Python discovery and requests.
#[arg(
long,
short,
env = EnvVars::UV_PYTHON,
value_parser = parse_maybe_string,
value_hint = ValueHint::Other,
)]
pub python: Option<Maybe<String>>,
/// The version of ty to use for type checking.
///
/// Accepts either a version (e.g., `0.0.1`) which will be treated as an exact pin,
/// a version specifier (e.g., `>=0.0.1`), or `latest` to use the latest available version.
///
/// By default, a constrained version range of ty will be used (e.g., `>=0.0,<0.1`).
#[arg(long, value_hint = ValueHint::Other)]
pub version: Option<String>,
/// Additional arguments to pass to ty.
///
/// For example, use `uv check -- --warn-on error` to treat warnings as errors or
/// `uv check -- src/module/foo.py` to check a specific file.
#[arg(last = true, value_hint = ValueHint::Other)]
pub extra_args: Vec<String>,
/// Avoid discovering a project or workspace.
///
/// Instead of running the type checker in the context of the current project, run it in the
/// context of the current directory. This is useful when the current directory is not a
/// project.
#[arg(
long,
env = EnvVars::UV_NO_PROJECT,
value_parser = clap::builder::BoolishValueParser::new()
)]
pub no_project: bool,
/// Display the version of ty that will be used for type checking.
#[arg(long, hide = true)]
pub show_version: bool,
#[command(flatten)]
pub installer: ResolverInstallerArgs,
#[command(flatten)]
pub build: BuildOptionsArgs,
#[command(flatten)]
pub refresh: RefreshArgs,
}
#[derive(Args)]
pub struct AuditArgs {
/// Don't audit the specified optional dependencies.
+4
View File
@@ -255,6 +255,7 @@ pub enum PreviewFeature {
TomlBackwardsCompatibility = 1 << 30,
MalwareCheck = 1 << 31,
VenvSafeClear = 1 << 32,
Check = 1 << 33,
}
impl PreviewFeature {
@@ -294,6 +295,7 @@ impl PreviewFeature {
Self::TomlBackwardsCompatibility => "toml-backwards-compatibility",
Self::MalwareCheck => "malware-check",
Self::VenvSafeClear => "venv-safe-clear",
Self::Check => "check",
}
}
}
@@ -346,6 +348,7 @@ impl FromStr for PreviewFeature {
"toml-backwards-compatibility" => Self::TomlBackwardsCompatibility,
"malware-check" => Self::MalwareCheck,
"venv-safe-clear" => Self::VenvSafeClear,
"check" => Self::Check,
_ => return Err(PreviewFeatureParseError),
})
}
@@ -606,6 +609,7 @@ mod tests {
);
assert_eq!(PreviewFeature::MalwareCheck.as_str(), "malware-check");
assert_eq!(PreviewFeature::VenvSafeClear.as_str(), "venv-safe-clear");
assert_eq!(PreviewFeature::Check.as_str(), "check");
}
#[test]
+10
View File
@@ -1431,6 +1431,16 @@ impl TestContext {
command
}
/// Create a `uv check` command with options shared across scenarios.
pub fn check(&self) -> Command {
let mut command = self.new_command();
command.arg("check");
self.add_shared_options(&mut command, false);
// Override to a more recent date for ty version resolution
command.env(EnvVars::UV_EXCLUDE_NEWER, "2026-02-15T00:00:00Z");
command
}
/// Create a `uv build` command with options shared across scenarios.
pub fn build(&self) -> Command {
let mut command = self.new_command();
+1
View File
@@ -32,6 +32,7 @@ pub(crate) use pip::tree::pip_tree;
pub(crate) use pip::uninstall::pip_uninstall;
pub(crate) use project::add::add;
pub(crate) use project::audit::audit;
pub(crate) use project::check::check;
pub(crate) use project::export::export;
pub(crate) use project::format::format;
pub(crate) use project::init::{InitKind, InitProjectKind, init};
+347
View File
@@ -0,0 +1,347 @@
use std::fmt::Write;
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result};
use tokio::process::Command;
use tracing::debug;
use uv_bin_install::{BinVersion, Binary, ResolvedVersion, bin_install, find_matching_version};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{
Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions,
};
use uv_normalize::DefaultExtras;
use uv_preview::{Preview, PreviewFeature};
use uv_python::{PythonDownloads, PythonPreference, PythonRequest};
use uv_settings::{MalwareCheckSettings, PythonInstallMirrors};
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError};
use crate::child::run_to_completion;
use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger};
use crate::commands::pip::operations::Modifications;
use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::LockMode;
use crate::commands::project::{
ProjectEnvironment, ProjectError, UniversalState, default_dependency_groups,
};
use crate::commands::reporters::BinaryDownloadReporter;
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
use crate::settings::{FrozenSource, LockCheck, ResolverInstallerSettings};
/// Run the type checker.
#[expect(clippy::fn_params_excessive_bools)]
pub(crate) async fn check(
project_dir: &Path,
lock_check: LockCheck,
frozen: Option<FrozenSource>,
no_sync: bool,
extras: ExtrasSpecification,
groups: DependencyGroups,
python: Option<String>,
install_mirrors: PythonInstallMirrors,
settings: ResolverInstallerSettings,
extra_args: Vec<String>,
version: Option<String>,
show_version: bool,
client_builder: BaseClientBuilder<'_>,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
installer_metadata: bool,
concurrency: Concurrency,
cache: &Cache,
workspace_cache: &WorkspaceCache,
printer: Printer,
preview: Preview,
no_project: bool,
no_config: bool,
malware_settings: MalwareCheckSettings,
) -> Result<ExitStatus> {
if !preview.is_enabled(PreviewFeature::Check) {
warn_user!(
"`uv check` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeature::Check
);
}
// Discover the project.
let project = if no_project {
None
} else {
match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache)
.await
{
Ok(project) => Some(project),
Err(
WorkspaceError::MissingPyprojectToml
| WorkspaceError::MissingProject(_)
| WorkspaceError::NonWorkspace(_),
) => None,
Err(err) => return Err(err.into()),
}
};
if no_project {
for flag in extras.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used alongside `--no-project`");
}
for flag in groups.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used alongside `--no-project`");
}
if let LockCheck::Enabled(lock_check) = lock_check {
warn_user!("`{lock_check}` has no effect when used alongside `--no-project`");
}
if frozen.is_some() {
warn_user!("`--frozen` has no effect when used alongside `--no-project`");
}
if no_sync {
warn_user!("`--no-sync` has no effect when used alongside `--no-project`");
}
} else if project.is_none() {
for flag in extras.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used outside of a project");
}
for flag in groups.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used outside of a project");
}
if let LockCheck::Enabled(lock_check) = lock_check {
warn_user!("`{lock_check}` has no effect when used outside of a project");
}
if frozen.is_some() {
warn_user!("`--frozen` has no effect when used outside of a project");
}
if no_sync {
warn_user!("`--no-sync` has no effect when used outside of a project");
}
}
let target_dir = project
.as_ref()
.map(|p| p.root().to_owned())
.unwrap_or_else(|| project_dir.to_owned());
// If we found a project, sync the environment before type checking.
let venv_path = if let Some(project) = &project {
let default_groups = default_dependency_groups(project.pyproject_toml())?;
let default_extras = DefaultExtras::default();
let groups = groups.with_defaults(default_groups);
let extras = extras.with_defaults(default_extras);
let venv = ProjectEnvironment::get_or_init(
project.workspace(),
&groups,
python.as_deref().map(PythonRequest::parse),
&install_mirrors,
&client_builder,
python_preference,
python_downloads,
no_sync,
no_config,
None,
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;
if no_sync {
debug!("Skipping environment synchronization due to `--no-sync`");
} else {
let _lock = venv
.lock()
.await
.inspect_err(|err| {
tracing::warn!("Failed to acquire environment lock: {err}");
})
.ok();
let lock_state = UniversalState::default();
let sync_state = lock_state.fork();
let mode = if let Some(frozen_source) = frozen {
LockMode::Frozen(frozen_source.into())
} else if let LockCheck::Enabled(lock_check) = lock_check {
LockMode::Locked(venv.interpreter(), lock_check)
} else {
LockMode::Write(venv.interpreter())
};
let result = match Box::pin(
project::lock::LockOperation::new(
mode,
&settings.resolver,
&client_builder,
&lock_state,
Box::new(SummaryResolveLogger),
&concurrency,
cache,
workspace_cache,
printer,
preview,
)
.execute(project.workspace().into()),
)
.await
{
Ok(result) => result,
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(err) => return Err(err.into()),
};
let target = match project {
VirtualProject::Project(project) => InstallTarget::Project {
workspace: project.workspace(),
name: project.project_name(),
lock: result.lock(),
},
VirtualProject::NonProject(workspace) => InstallTarget::NonProjectWorkspace {
workspace,
lock: result.lock(),
},
};
target.validate_extras(&extras)?;
target.validate_groups(&groups)?;
match project::sync::do_sync(
target,
&venv,
&extras,
&groups,
None,
InstallOptions::default(),
Modifications::Sufficient,
None,
(&settings).into(),
&client_builder,
&sync_state,
Box::new(SummaryInstallLogger),
installer_metadata,
&concurrency,
cache,
workspace_cache,
DryRun::Disabled,
printer,
preview,
&malware_settings,
)
.await
{
Ok(_) => {}
Err(ProjectError::Operation(err)) => {
return diagnostics::OperationDiagnostic::with_system_certs(
client_builder.system_certs(),
)
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(err) => return Err(err.into()),
}
}
Some(venv.root().to_owned())
} else {
None
};
// Download and install ty.
let exclude_newer = settings
.resolver
.exclude_newer
.global
.map(|v| v.timestamp());
let retry_policy = client_builder.retry_policy();
let ty_client = client_builder.clone().retries(0).build()?;
let reporter = BinaryDownloadReporter::single(printer);
let bin_version = version
.as_deref()
.map(BinVersion::from_str)
.transpose()?
.unwrap_or(BinVersion::Default);
let resolved = match bin_version {
BinVersion::Default => {
let constraints = Binary::Ty.default_constraints();
let resolved = find_matching_version(
Binary::Ty,
Some(&constraints),
exclude_newer,
&ty_client,
&retry_policy,
)
.await
.with_context(|| {
format!("Failed to find ty version matching default constraints: {constraints}")
})?;
debug!("Resolved `ty@{constraints}` to `ty=={}`", resolved.version);
resolved
}
BinVersion::Pinned(version) => {
if exclude_newer.is_some() {
debug!("`--exclude-newer` is ignored for pinned version `{version}`");
}
ResolvedVersion::from_version(Binary::Ty, version)?
}
BinVersion::Latest => {
let resolved =
find_matching_version(Binary::Ty, None, exclude_newer, &ty_client, &retry_policy)
.await
.with_context(|| "Failed to find latest ty version")?;
debug!("Resolved `ty@latest` to `ty=={}`", resolved.version);
resolved
}
BinVersion::Constraint(constraints) => {
let resolved = find_matching_version(
Binary::Ty,
Some(&constraints),
exclude_newer,
&ty_client,
&retry_policy,
)
.await
.with_context(|| format!("Failed to find ty version matching: {constraints}"))?;
debug!("Resolved `ty@{constraints}` to `ty=={}`", resolved.version);
resolved
}
};
if show_version {
writeln!(printer.stderr(), "ty {}", resolved.version)?;
}
let ty_path = bin_install(
Binary::Ty,
&resolved,
&ty_client,
&retry_policy,
cache,
&reporter,
)
.await
.with_context(|| format!("Failed to install ty {}", resolved.version))?;
let mut command = Command::new(&ty_path);
command.current_dir(&target_dir);
command.arg("check");
if let Some(venv_path) = &venv_path {
command.env("VIRTUAL_ENV", venv_path);
}
command.args(extra_args.iter());
let handle = command.spawn().context("Failed to spawn `ty check`")?;
run_to_completion(handle).await
}
+1
View File
@@ -66,6 +66,7 @@ use crate::settings::{
pub(crate) mod add;
pub(crate) mod audit;
pub(crate) mod check;
pub(crate) mod environment;
pub(crate) mod export;
pub(crate) mod format;
+45
View File
@@ -2687,6 +2687,51 @@ async fn run_project(
))
.await
}
ProjectCommand::Check(args) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::CheckSettings::resolve(args, filesystem, environment);
show_settings!(args);
// Check for conflicts between offline and refresh.
globals
.network_settings
.check_refresh_conflict(&args.refresh);
// Initialize the cache.
let cache = cache.init().await?.with_refresh(
args.refresh
.combine(Refresh::from(args.settings.reinstall.clone()))
.combine(Refresh::from(args.settings.resolver.upgrade.clone())),
);
Box::pin(commands::check(
project_dir,
args.lock_check,
args.frozen,
args.no_sync,
args.extras,
args.groups,
args.python,
args.install_mirrors,
args.settings,
args.extra_args,
args.version,
args.show_version,
client_builder.subcommand(vec!["check".to_owned()]),
globals.python_preference,
globals.python_downloads,
globals.installer_metadata,
globals.concurrency,
&cache,
workspace_cache,
printer,
globals.preview,
args.no_project,
no_config,
args.malware_settings,
))
.await
}
ProjectCommand::Audit(audit_args) => {
let args = settings::AuditSettings::resolve(audit_args, filesystem, environment);
show_settings!(args);
+118 -1
View File
@@ -22,7 +22,7 @@ use uv_cli::{
ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, VersionBumpSpec, VersionFormat,
};
use uv_cli::{
AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs,
AuthorFrom, BuildArgs, CheckArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs,
ResolverInstallerArgs, ToolUpgradeArgs,
options::{
Flag, FlagSource, check_conflicts, flag, resolve_flag, resolve_flag_pair,
@@ -2742,6 +2742,123 @@ impl FormatSettings {
}
}
/// The resolved settings to use for a `check` invocation.
#[derive(Debug, Clone)]
pub(crate) struct CheckSettings {
pub(crate) extras: ExtrasSpecification,
pub(crate) groups: DependencyGroups,
pub(crate) lock_check: LockCheck,
pub(crate) frozen: Option<FrozenSource>,
pub(crate) no_sync: bool,
pub(crate) python: Option<String>,
pub(crate) install_mirrors: PythonInstallMirrors,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings,
pub(crate) extra_args: Vec<String>,
pub(crate) version: Option<String>,
pub(crate) no_project: bool,
pub(crate) show_version: bool,
pub(crate) malware_settings: MalwareCheckSettings,
}
impl CheckSettings {
/// Resolve the [`CheckSettings`] from the CLI and filesystem configuration.
pub(crate) fn resolve(
args: CheckArgs,
filesystem: Option<FilesystemOptions>,
environment: EnvironmentOptions,
) -> Self {
let CheckArgs {
extra,
all_extras,
no_extra,
no_all_extras,
dev,
no_dev,
only_dev,
group,
no_group,
no_default_groups,
only_group,
all_groups,
locked,
frozen,
no_sync,
python,
version,
extra_args,
no_project,
show_version,
installer,
build,
refresh,
} = args;
let filesystem_install_mirrors = filesystem
.clone()
.map(|fs| fs.install_mirrors.clone())
.unwrap_or_default();
let locked = resolve_flag(locked, "locked", environment.locked);
let frozen = resolve_flag(frozen, "frozen", environment.frozen);
let no_sync = resolve_flag(no_sync, "no-sync", environment.no_sync);
check_conflicts(locked, frozen);
let (dev, no_dev) = resolve_flag_pair(
dev,
no_dev,
"dev",
"no-dev",
Some(environment.dev),
Some(environment.no_dev),
);
let settings = ResolverInstallerSettings::combine(
resolver_installer_options(installer, build),
filesystem,
&environment,
);
let malware_settings = MalwareCheckSettings::from(&environment);
Self {
extras: ExtrasSpecification::from_args(
extra.unwrap_or_default(),
no_extra,
false,
vec![],
flag(all_extras, no_all_extras, "all-extras").unwrap_or_default(),
),
groups: DependencyGroups::from_args(
dev.into(),
no_dev.into(),
only_dev,
group,
if no_group.is_empty() {
environment.no_group.clone().unwrap_or_default()
} else {
no_group
},
no_default_groups,
only_group,
all_groups,
),
lock_check: resolve_lock_check(locked),
frozen: resolve_frozen(frozen),
no_sync: no_sync.is_enabled(),
python: python.and_then(Maybe::into_option),
install_mirrors: environment
.install_mirrors
.combine(filesystem_install_mirrors),
refresh: Refresh::from(refresh),
settings,
extra_args,
version,
no_project,
show_version,
malware_settings,
}
}
}
/// The resolved settings to use for an `audit` invocation.
#[derive(Debug, Clone)]
pub(crate) struct AuditSettings {
+269
View File
@@ -0,0 +1,269 @@
use anyhow::Result;
use assert_fs::prelude::*;
use indoc::indoc;
use uv_test::uv_snapshot;
#[test]
fn check_project() -> Result<()> {
let context = uv_test::test_context!("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
x: int = 1
"})?;
uv_snapshot!(context.filters(), context.check(), @"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
");
Ok(())
}
#[test]
fn check_missing_pyproject_toml() -> Result<()> {
let context = uv_test::test_context_with_versions!(&[]);
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
x: int = 1
"})?;
uv_snapshot!(context.filters(), context.check(), @"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
");
// Project-only settings are ignored without a discovered project.
uv_snapshot!(context.filters(), context.check().arg("--group").arg("dev").arg("--frozen").arg("--no-sync"), @"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
warning: `--group dev` has no effect when used outside of a project
warning: `--frozen` has no effect when used outside of a project
warning: `--no-sync` has no effect when used outside of a project
");
Ok(())
}
#[test]
fn check_no_project() -> Result<()> {
let context = uv_test::test_context_with_versions!(&[]).with_filtered_python_sources();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=4.0"
dependencies = []
"#})?;
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
x: int = 1
"})?;
uv_snapshot!(context.filters(), context.check(), @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
error: No interpreter found for Python >=4.0 in [PYTHON SOURCES]
");
// The unavailable project environment is not initialized when project discovery is disabled.
uv_snapshot!(context.filters(), context.check().arg("--no-project"), @"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
");
// Project-only settings are ignored when project discovery is disabled.
uv_snapshot!(
context.filters(),
context
.check()
.arg("--no-project")
.arg("--extra")
.arg("foo")
.arg("--group")
.arg("bar")
.arg("--locked")
.arg("--no-sync"),
@"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
warning: `--extra foo` has no effect when used alongside `--no-project`
warning: `--group bar` has no effect when used alongside `--no-project`
warning: `--locked` has no effect when used alongside `--no-project`
warning: `--no-sync` has no effect when used alongside `--no-project`
"
);
Ok(())
}
#[test]
fn check_type_error() -> Result<()> {
let context = uv_test::test_context_with_versions!(&[]);
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r#"
name: str = "project"
version: int = name
"#})?;
uv_snapshot!(context.filters(), context.check(), @r#"
success: false
exit_code: 1
----- stdout -----
error[invalid-assignment]: Object of type `Literal["project"]` is not assignable to `int`
--> main.py:2:10
|
1 | name: str = "project"
2 | version: int = name
| --- ^^^^ Incompatible value of type `Literal["project"]`
| |
| Declared type
|
info: rule `invalid-assignment` is enabled by default
Found 1 diagnostic
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
"#);
Ok(())
}
#[test]
#[cfg(feature = "test-pypi")]
fn check_with_declared_dependency() -> Result<()> {
let context = uv_test::test_context!("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#})?;
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
import iniconfig
"})?;
// ty should resolve the import via the synced virtual environment.
uv_snapshot!(context.filters(), context.check(), @"
success: true
exit_code: 0
----- stdout -----
All checks passed!
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
Installed 1 package in [TIME]
");
context
.assert_command(
"from importlib.metadata import distribution; assert distribution('iniconfig').read_text('INSTALLER') == 'uv'",
)
.success();
Ok(())
}
#[test]
fn check_with_undeclared_dependency() -> Result<()> {
let context = uv_test::test_context!("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
let main_py = context.temp_dir.child("main.py");
main_py.write_str(indoc! {r"
import iniconfig
"})?;
let filters = context
.filters()
.into_iter()
.chain([(
r"info: \d+\. \[VENV\]/lib64/python3\.12/site-packages \(site-packages\)\n",
"",
)])
.collect::<Vec<_>>();
// ty should report a diagnostic for the unresolvable import.
uv_snapshot!(filters, context.check(), @"
success: false
exit_code: 1
----- stdout -----
error[unresolved-import]: Cannot resolve imported module `iniconfig`
--> main.py:1:8
|
1 | import iniconfig
| ^^^^^^^^^
|
info: Searched in the following paths during module resolution:
info: 1. [TEMP_DIR]/ (first-party code)
info: 2. vendored://stdlib (stdlib typeshed stubs vendored by ty)
info: 3. [SITE_PACKAGES]/ (site-packages)
info: make sure your Python environment is properly configured: https://docs.astral.sh/ty/modules/#python-environment
info: rule `unresolved-import` is enabled by default
Found 1 diagnostic
----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check` to disable this warning.
");
Ok(())
}
+7
View File
@@ -27,6 +27,7 @@ fn help() {
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
check Type check the project
audit Audit the project's dependencies
tool Run and install commands provided by Python packages
python Manage Python versions and installations
@@ -110,6 +111,7 @@ fn help_flag() {
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
check Type check the project
audit Audit the project's dependencies
tool Run and install commands provided by Python packages
python Manage Python versions and installations
@@ -192,6 +194,7 @@ fn help_short_flag() {
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
check Type check the project
audit Audit the project's dependencies
tool Run and install commands provided by Python packages
python Manage Python versions and installations
@@ -975,6 +978,7 @@ fn help_unknown_subcommand() {
export
tree
format
check
audit
tool
python
@@ -1006,6 +1010,7 @@ fn help_unknown_subcommand() {
export
tree
format
check
audit
tool
python
@@ -1066,6 +1071,7 @@ fn help_with_global_option() {
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
check Type check the project
audit Audit the project's dependencies
tool Run and install commands provided by Python packages
python Manage Python versions and installations
@@ -1190,6 +1196,7 @@ fn help_with_no_pager() {
export Export the project's lockfile to an alternate format
tree Display the project's dependency tree
format Format Python code in the project
check Type check the project
audit Audit the project's dependencies
tool Run and install commands provided by Python packages
python Manage Python versions and installations
+3
View File
@@ -39,6 +39,9 @@ mod edit;
#[cfg(all(feature = "test-python", feature = "test-pypi"))]
mod export;
#[cfg(feature = "test-python")]
mod check;
#[cfg(all(feature = "test-python", feature = "test-pypi"))]
mod format;
+2
View File
@@ -8639,6 +8639,7 @@ fn preview_features() {
TomlBackwardsCompatibility,
MalwareCheck,
VenvSafeClear,
Check,
],
},
python_preference: Managed,
@@ -8923,6 +8924,7 @@ fn preview_features() {
TomlBackwardsCompatibility,
MalwareCheck,
VenvSafeClear,
Check,
],
},
python_preference: Managed,