Skip to content

Commit

Permalink
Implement JSON and CSV output serialization (#38)
Browse files Browse the repository at this point in the history
* Implement JSON and CSV output serialization

* Bump version

* Better error handling, Readme, rename pretty to default and JSON pretty

* Update src/lib.rs

Co-authored-by: Pavel Zwerschke <pavelzw@gmail.com>

* Update Readme with example output formats

---------

Co-authored-by: Pavel Zwerschke <pavelzw@gmail.com>
  • Loading branch information
PaulKMueller and pavelzw authored Feb 17, 2025
1 parent 2dd024f commit 5a7d1f3
Show file tree
Hide file tree
Showing 11 changed files with 229 additions and 39 deletions.
24 changes: 23 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "conda-deny"
description = "A CLI tool to check your project's dependencies for license compliance."
version = "0.4.0"
version = "0.4.1"
edition = "2021"

[features]
Expand Down Expand Up @@ -36,6 +36,7 @@ clap-verbosity-flag = "3.0.2"
env_logger = "0.11.6"
log = "0.4.25"
tempfile = "3.16.0"
csv = "1.3.1"

[dev-dependencies]
assert_cmd = "2.0.14"
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,40 @@ ignore-packages = [

After installing `conda-deny`, you can run `conda-deny check` in your project.
This then checks `pixi.lock` to determine the packages (and their versions) used in your project.

### ✨ Output Formats

`conda-deny` supports different output formats via the `--output` (or `-o`) flag.
Output formatting works for both, the `list` and the `check` command.
To get an overview of the different format options, try:

```bash
$ conda-deny list --output csv
package_name,version,license,platform,build,safe
_openmp_mutex,4.5,BSD-3-Clause,linux-aarch64,2_gnu,false
_openmp_mutex,4.5,BSD-3-Clause,linux-64,2_gnu,false
...

$ conda-deny list --output json-pretty
{
"unsafe": [
{
"build": "conda_forge",
"license": {
"Invalid": "None"
},
"package_name": "_libgcc_mutex",
"platform": "linux-64",
"version": "0.1"
},
{
"build": "h57d6b7b_14",
"license": {
"Invalid": "LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later AND MPL-2.0"
},
"package_name": "_sysroot_linux-aarch64_curr_repodata_hack",
"platform": "noarch",
"version": "4"
},
...
```
75 changes: 66 additions & 9 deletions src/check.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
use crate::{fetch_license_infos, license_info::LicenseInfo, CheckOutput, CondaDenyCheckConfig};
use crate::{
fetch_license_infos,
license_info::{LicenseInfo, LicenseState},
CheckOutput, CondaDenyCheckConfig, OutputFormat,
};
use anyhow::{Context, Result};
use colored::Colorize;
use log::debug;
use serde::Serialize;
use serde_json::json;
use std::io::Write;

fn check_license_infos(config: &CondaDenyCheckConfig) -> Result<CheckOutput> {
Expand All @@ -20,14 +26,65 @@ fn check_license_infos(config: &CondaDenyCheckConfig) -> Result<CheckOutput> {
pub fn check<W: Write>(check_config: CondaDenyCheckConfig, mut out: W) -> Result<()> {
let (safe_dependencies, unsafe_dependencies) = check_license_infos(&check_config)?;

writeln!(
out,
"{}",
format_check_output(
safe_dependencies,
unsafe_dependencies.clone(),
)
)?;
match check_config.output_format {
OutputFormat::Default => {
writeln!(
out,
"{}",
format_check_output(safe_dependencies, unsafe_dependencies.clone(),)
)?;
}
OutputFormat::Json => {
let json_output = json!({
"safe": safe_dependencies,
"unsafe": unsafe_dependencies,
});
writeln!(out, "{}", json_output)?;
}
OutputFormat::JsonPretty => {
let json_output = json!({
"safe": safe_dependencies,
"unsafe": unsafe_dependencies,
});
writeln!(out, "{}", serde_json::to_string_pretty(&json_output)?)?;
}
OutputFormat::Csv => {
#[derive(Debug, Clone, Serialize)]
struct LicenseInfoWithSafety {
package_name: String,
version: String,
license: LicenseState,
platform: Option<String>,
build: String,
safe: bool,
}

let mut writer = csv::WriterBuilder::new().from_writer(vec![]);

for (license_info, is_safe) in unsafe_dependencies
.iter()
.map(|x: &LicenseInfo| (x, false))
.chain(safe_dependencies.iter().map(|x: &LicenseInfo| (x, true)))
{
let extended_info = LicenseInfoWithSafety {
package_name: license_info.package_name.clone(),
version: license_info.version.clone(),
license: license_info.license.clone(),
platform: license_info.platform.clone(),
build: license_info.build.clone(),
safe: is_safe,
};
writer.serialize(&extended_info).with_context(|| {
format!(
"Failed to serialize the following LicenseInfo to CSV: {:?}",
extended_info
)
})?;
}

out.write_all(&writer.into_inner()?)?;
}
}

if !unsafe_dependencies.is_empty() {
Err(anyhow::anyhow!("Unsafe licenses found"))
Expand Down
19 changes: 18 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use clap_verbosity_flag::{ErrorLevel, Verbosity};
use clap::Parser;
use rattler_conda_types::Platform;

use crate::OutputFormat;

#[derive(Parser, Debug)]
#[command(name = "conda-deny", about = "Check and list licenses of pixi and conda environments", version = env!("CARGO_PKG_VERSION"))]
pub struct Cli {
Expand Down Expand Up @@ -40,12 +42,16 @@ pub enum CondaDenyCliConfig {
environment: Option<Vec<String>>,

/// Check against OSI licenses instead of custom license whitelists.
#[arg(short, long)]
#[arg(long)]
osi: Option<bool>,

/// Ignore when encountering pypi packages instead of failing.
#[arg(long)]
ignore_pypi: Option<bool>,

/// Output format
#[arg(short, long)]
output: Option<OutputFormat>,
},
/// List all packages and their licenses in your conda or pixi environment
List {
Expand All @@ -68,6 +74,10 @@ pub enum CondaDenyCliConfig {
/// Ignore when encountering pypi packages instead of failing.
#[arg(long)]
ignore_pypi: Option<bool>,

/// Output format
#[arg(short, long)]
output: Option<OutputFormat>,
},
}

Expand Down Expand Up @@ -106,6 +116,13 @@ impl CondaDenyCliConfig {
CondaDenyCliConfig::List { ignore_pypi, .. } => *ignore_pypi,
}
}

pub fn output_format(&self) -> Option<OutputFormat> {
match self {
CondaDenyCliConfig::Check { output, .. } => *output,
CondaDenyCliConfig::List { output, .. } => *output,
}
}
}

#[cfg(test)]
Expand Down
6 changes: 0 additions & 6 deletions src/conda_deny_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,6 @@ pub enum LockfileSpec {
Multiple(Vec<PathBuf>),
}

#[derive(Debug, Deserialize)]
struct PixiEnvironmentEntry {
_file: String,
_environments: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct CondaDeny {
#[serde(rename = "license-whitelist")]
Expand Down
30 changes: 23 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use license_whitelist::{get_license_information_from_toml_config, IgnorePackage}
use anyhow::{Context, Result};
use log::debug;
use rattler_conda_types::Platform;
use serde::Deserialize;
use spdx::Expression;

use crate::license_info::LicenseInfos;
Expand All @@ -29,19 +30,31 @@ pub enum CondaDenyConfig {
List(CondaDenyListConfig),
}

#[derive(Debug, Clone, clap::ValueEnum, Default, Deserialize, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
#[default]
Default,
Json,
JsonPretty,
Csv,
}

/// Configuration for the check command
#[derive(Debug)]
pub struct CondaDenyCheckConfig {
pub lockfile_or_prefix: LockfileOrPrefix,
pub osi: bool,
pub safe_licenses: Vec<Expression>,
pub ignore_packages: Vec<IgnorePackage>,
pub output_format: OutputFormat,
}

/// Shared configuration between check and list commands
#[derive(Debug)]
pub struct CondaDenyListConfig {
pub lockfile_or_prefix: LockfileOrPrefix,
pub output_format: OutputFormat,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -94,6 +107,7 @@ fn get_lockfile_or_prefix(
}))
}
} else if !lockfile.is_empty() && !prefix.is_empty() {
// TODO: Specified prefixes override lockfiles
Err(anyhow::anyhow!(
"Both lockfiles and conda prefixes provided. Please only provide either or."
))
Expand All @@ -113,7 +127,7 @@ fn get_lockfile_or_prefix(
))
} else if environments.is_some() {
Err(anyhow::anyhow!(
"Cannot specify environments and conda prefixes at the same time"
"Cannot specify pixi environments and conda prefixes at the same time"
))
} else if ignore_pypi.is_some() {
Err(anyhow::anyhow!(
Expand Down Expand Up @@ -202,13 +216,13 @@ pub fn get_config_options(
toml_config.get_ignore_pypi()
};

let output_format = cli_config.output_format().unwrap_or_default();

let lockfile_or_prefix =
get_lockfile_or_prefix(lockfile, prefix, platforms, environments, ignore_pypi)?;

let config = match cli_config {
CondaDenyCliConfig::Check {
osi, ..
} => {
CondaDenyCliConfig::Check { osi, .. } => {
// defaults to false
let osi = if osi.is_some() {
osi
Expand All @@ -234,11 +248,13 @@ pub fn get_config_options(
osi,
safe_licenses,
ignore_packages,
output_format,
})
}
CondaDenyCliConfig::List { .. } => {
CondaDenyConfig::List(CondaDenyListConfig { lockfile_or_prefix })
}
CondaDenyCliConfig::List { .. } => CondaDenyConfig::List(CondaDenyListConfig {
lockfile_or_prefix,
output_format,
}),
};

Ok(config)
Expand Down
Loading

0 comments on commit 5a7d1f3

Please sign in to comment.