Repository: mitre/hipcheck
Pull request #1011: feat(xtask): xtask validates Python projects
Author: login: mchernicoff
Labels: name: product: project
description: Relates to the project itself
name: product: python-sdk
description: Relates to the Python plugin SDK
Created: 2025-03-11T18:41:39Z | Merged: 2025-03-12T20:16:09Z

Resolves issue #1008

`xtask validate` now validates both Rust crates and Python projects. For now, we manually include Hipcheck's only Python project in the list of code packages to examine.


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 66ccbc0dd..096e34074 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2309,6 +2309,7 @@ dependencies = [
  "hashbrown 0.15.2",
  "idna",
  "indexmap 1.9.3",
+ "indexmap 2.7.1",
  "itertools 0.13.0",
  "jiff",
  "libc",
@@ -3462,6 +3463,41 @@ dependencies = [
  "hmac",
 ]
 
+[[package]]
+name = "pep440_rs"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c"
+dependencies = [
+ "once_cell",
+ "serde",
+ "unicode-width 0.2.0",
+ "unscanny",
+ "version-ranges",
+]
+
+[[package]]
+name = "pep508_rs"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faee7227064121fcadcd2ff788ea26f0d8f2bd23a0574da11eca23bc935bcc05"
+dependencies = [
+ "boxcar",
+ "indexmap 2.7.1",
+ "itertools 0.13.0",
+ "once_cell",
+ "pep440_rs",
+ "regex",
+ "rustc-hash",
+ "serde",
+ "smallvec",
+ "thiserror 1.0.69",
+ "unicode-width 0.2.0",
+ "url",
+ "urlencoding",
+ "version-ranges",
+]
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.1"
@@ -3649,6 +3685,20 @@ dependencies = [
  "thiserror 1.0.69",
 ]
 
+[[package]]
+name = "pyproject-toml"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "643af57c3f36ba90a8b53e972727d8092f7408a9ebfbaf4c3d2c17b07c58d835"
+dependencies = [
+ "indexmap 2.7.1",
+ "pep440_rs",
+ "pep508_rs",
+ "serde",
+ "thiserror 1.0.69",
+ "toml",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.38"
@@ -5011,6 +5061,12 @@ dependencies = [
  "void",
 ]
 
+[[package]]
+name = "unscanny"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47"
+
 [[package]]
 name = "untrusted"
 version = "0.9.0"
@@ -5046,6 +5102,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
 [[package]]
 name = "utf16_iter"
 version = "1.0.5"
@@ -5085,6 +5147,15 @@ version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
 
+[[package]]
+name = "version-ranges"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8d079415ceb2be83fc355adbadafe401307d5c309c7e6ade6638e6f9f42f42d"
+dependencies = [
+ "smallvec",
+]
+
 [[package]]
 name = "version_check"
 version = "0.9.4"
@@ -5494,6 +5565,8 @@ dependencies = [
  "kdl 4.7.1",
  "log",
  "pathbuf",
+ "pep440_rs",
+ "pyproject-toml",
  "regex",
  "rustls",
  "rustls-native-certs",
diff --git a/library/hipcheck-workspace-hack/Cargo.toml b/library/hipcheck-workspace-hack/Cargo.toml
index ccc4503e4..b1e8c712a 100644
--- a/library/hipcheck-workspace-hack/Cargo.toml
+++ b/library/hipcheck-workspace-hack/Cargo.toml
@@ -34,7 +34,8 @@ getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2.15", defaul
 hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15.2" }
 hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.5", features = ["raw"] }
 idna = { version = "1.0.3", default-features = false, features = ["compiled_data", "std"] }
-indexmap = { version = "1.9.3", default-features = false, features = ["serde-1"] }
+indexmap-dff4ba8e3ae991db = { package = "indexmap", version = "1.9.3", default-features = false, features = ["serde-1"] }
+indexmap-f595c2ba2a3f28df = { package = "indexmap", version = "2.7.1", features = ["serde"] }
 itertools = { version = "0.13.0" }
 jiff = { version = "0.1.16", features = ["serde"] }
 libz-sys = { version = "1.1.20", default-features = false, features = ["libc"] }
@@ -53,7 +54,7 @@ schemars = { version = "0.8.22", features = ["chrono", "preserve_order", "url"]
 semver = { version = "1.0.25", features = ["serde"] }
 serde = { version = "1.0.219", features = ["alloc", "derive", "rc"] }
 sha2 = { version = "0.10.8" }
-smallvec = { version = "1.13.2", default-features = false, features = ["const_new", "write"] }
+smallvec = { version = "1.13.2", default-features = false, features = ["const_new", "union", "write"] }
 tokio = { version = "1.44.0", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
 tokio-stream = { version = "0.1.17", features = ["net"] }
 unicode-normalization = { version = "0.1.24" }
@@ -82,7 +83,8 @@ getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2.15", defaul
 hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15.2" }
 hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.5", features = ["raw"] }
 idna = { version = "1.0.3", default-features = false, features = ["compiled_data", "std"] }
-indexmap = { version = "1.9.3", default-features = false, features = ["serde-1"] }
+indexmap-dff4ba8e3ae991db = { package = "indexmap", version = "1.9.3", default-features = false, features = ["serde-1"] }
+indexmap-f595c2ba2a3f28df = { package = "indexmap", version = "2.7.1", features = ["serde"] }
 itertools = { version = "0.13.0" }
 jiff = { version = "0.1.16", features = ["serde"] }
 libz-sys = { version = "1.1.20", default-features = false, features = ["libc"] }
@@ -101,7 +103,7 @@ schemars = { version = "0.8.22", features = ["chrono", "preserve_order", "url"]
 semver = { version = "1.0.25", features = ["serde"] }
 serde = { version = "1.0.219", features = ["alloc", "derive", "rc"] }
 sha2 = { version = "0.10.8" }
-smallvec = { version = "1.13.2", default-features = false, features = ["const_new", "write"] }
+smallvec = { version = "1.13.2", default-features = false, features = ["const_new", "union", "write"] }
 syn = { version = "2.0.96", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
 tokio = { version = "1.44.0", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
 tokio-stream = { version = "0.1.17", features = ["net"] }
diff --git a/sdk/python/LICENSE.md b/sdk/python/LICENSE.md
new file mode 100644
index 000000000..ff42e8610
--- /dev/null
+++ b/sdk/python/LICENSE.md
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2024 The MITRE Corporation
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index c3a22f857..35f8d456c 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -7,6 +7,7 @@ authors = [
     { name = "jlanson", email = "jlanson@mitre.org" }
 ]
 requires-python = ">=3.10"
+license-files = ["LICENSE.md"]
 dependencies = [
     "asyncio>=3.4.3",
     "grpcio>=1.70.0",
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index b1ef7e924..d1ff89627 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -17,6 +17,8 @@ env_logger = "0.11.6"
 log = "0.4.25"
 glob = "0.3.2"
 pathbuf = "1.0.0"
+pep440_rs = "0.7.3"
+pyproject-toml = "0.13.4"
 serde = { version = "1.0.219", features = ["derive"] }
 toml = "0.8.20"
 xshell = "0.2.7"
diff --git a/xtask/src/build.rs b/xtask/src/build.rs
index 59e85009c..c399e41b0 100644
--- a/xtask/src/build.rs
+++ b/xtask/src/build.rs
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: Apache-2.0
+
 use crate::{BuildPkg, BuildProfile};
 use std::{collections::BTreeSet, fmt::Display};
 
diff --git a/xtask/src/string.rs b/xtask/src/string.rs
index e1ce881e5..b1dfd59cf 100644
--- a/xtask/src/string.rs
+++ b/xtask/src/string.rs
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: Apache-2.0
+
 use itertools::Itertools as _;
 
 /// List a bunch of strings together separated by commas.
diff --git a/xtask/src/task/validate.rs b/xtask/src/task/validate.rs
index 0d80b70b5..e88c54fe4 100644
--- a/xtask/src/task/validate.rs
+++ b/xtask/src/task/validate.rs
@@ -6,19 +6,20 @@ use crate::workspace;
 use anyhow::{anyhow, Context as _, Result};
 use glob::glob;
 use pathbuf::pathbuf;
+use pep440_rs::{Version, VersionSpecifiers};
+use pyproject_toml::{Contact, License, Project, PyProjectToml};
 use serde::{de::DeserializeOwned, Deserialize};
 use std::{
 	collections::BTreeSet,
-	fmt,
-	fmt::{Debug, Display, Formatter},
-	fs,
-	fs::File,
+	fmt::{self, Debug, Display, Formatter},
+	fs::{self, File},
 	io::{BufRead, BufReader},
 	ops::Not as _,
 	path::{Path, PathBuf},
+	str::FromStr,
 };
 
-/// Print list of validation failures for crates in the workspace.
+/// Print list of validation failures for packages in the workspace.
 pub fn run() -> Result<()> {
 	log::info!("beginning validation");
 
@@ -26,15 +27,22 @@ pub fn run() -> Result<()> {
 	let findings = Findings::for_workspace(&workspace);
 	findings.report()?;
 
-	log::info!("all checks passed!");
+	if findings.package_findings.is_empty()
+		&& findings.config_findings.is_empty()
+		&& findings.source_findings.is_empty()
+	{
+		log::info!("all checks passed!");
+	} else {
+		log::info!("not all checks passed");
+	}
 	Ok(())
 }
 
-/// Set of crate findings.
-type CrateFindingsSet = BTreeSet<CrateIssues>;
+/// Set of package findings.
+type PackageFindingsSet = BTreeSet<PackageIssues>;
 
-/// Vector (rather than HashMap) mapping crates to findings.
-type CrateFindings<'work> = Vec<(&'work Crate, CrateFindingsSet)>;
+/// Vector (rather than HashMap) mapping packages to findings.
+type PackageFindings<'work> = Vec<(&'work Package, PackageFindingsSet)>;
 
 /// Set of config findings.
 type ConfigFindingsSet = BTreeSet<ConfigIssues>;
@@ -45,32 +53,32 @@ type ConfigFindings<'work> = Vec<(&'work Path, ConfigFindingsSet)>;
 /// Set of source findings.
 type SourceFindingsSet = Vec<(PathBuf, SourceIssues)>;
 
-/// Vector (rather than HashMap) mapping config files to findings.
-type SourceFindings<'work> = Vec<(&'work Crate, SourceFindingsSet)>;
+/// Vector (rather than HashMap) mapping packages to source findings.
+type SourceFindings<'work> = Vec<(&'work Package, SourceFindingsSet)>;
 
-/// Maps crates to findings.
+/// Maps packages to findings.
 ///
 /// Retains a reference to the overall workspace because it's needed when printing results.
 struct Findings<'work> {
 	/// Reference to the workspace (kept for printing results)
 	workspace: &'work Workspace,
-	/// The findings for each crate.
-	crate_findings: CrateFindings<'work>,
+	/// The findings for each package.
+	package_findings: PackageFindings<'work>,
 	/// Findings for the Hipcheck configuration files.
 	config_findings: ConfigFindings<'work>,
-	/// Findings for source files.
+	/// Findings for Rust source files.
 	source_findings: SourceFindings<'work>,
 }
 
 impl<'work> Findings<'work> {
-	/// Perform validation of crates in the workspace.
+	/// Perform validation of packages in the workspace.
 	fn for_workspace(workspace: &'work Workspace) -> Findings<'work> {
-		let crate_findings: CrateFindings<'work> = workspace
-			.crates
+		let package_findings: PackageFindings<'work> = workspace
+			.packages
 			.iter()
-			.fold(Vec::new(), |mut crate_findings, krate| {
-				crate_findings.push((krate, validate_crate(krate)));
-				crate_findings
+			.fold(Vec::new(), |mut package_findings, package| {
+				package_findings.push((package, validate_package(package)));
+				package_findings
 			})
 			.into_iter()
 			.filter(|(_, findings)| findings.is_empty().not())
@@ -88,10 +96,10 @@ impl<'work> Findings<'work> {
 			.collect();
 
 		let source_findings: SourceFindings<'work> = workspace
-			.crates
+			.packages
 			.iter()
-			.fold(Vec::new(), |mut source_findings, krate| {
-				source_findings.push((krate, validate_sources(krate)));
+			.fold(Vec::new(), |mut source_findings, package| {
+				source_findings.push((package, validate_sources(package)));
 				source_findings
 			})
 			.into_iter()
@@ -100,7 +108,7 @@ impl<'work> Findings<'work> {
 
 		Findings {
 			workspace,
-			crate_findings,
+			package_findings,
 			config_findings,
 			source_findings,
 		}
@@ -108,13 +116,19 @@ impl<'work> Findings<'work> {
 
 	/// Report the findings.
 	fn report(&self) -> Result<()> {
-		for (krate, findings) in &self.crate_findings {
-			let krate_path = krate.path.strip_prefix(&self.workspace.root)?;
+		for (package, findings) in &self.package_findings {
+			let package_path = package.path.strip_prefix(&self.workspace.root)?;
+			let package_language = match package.config {
+				PackageConfig::Crate(_) => "Rust",
+				PackageConfig::PyProject(_) => "Python",
+			};
+
 			for finding in findings.iter() {
 				log::error!(
-					"crate: {}, crate_path: {}, name: {:?}, desc: {}",
-					krate.name,
-					krate_path.display(),
+					"package: {}, package_path: {}, package_language: {}, name: {:?}, desc: {}",
+					package.name,
+					package_path.display(),
+					package_language,
 					finding,
 					finding
 				);
@@ -134,14 +148,19 @@ impl<'work> Findings<'work> {
 			}
 		}
 
-		for (krate, findings) in &self.source_findings {
-			let krate_path = krate.path.strip_prefix(&self.workspace.root)?;
+		for (package, findings) in &self.source_findings {
+			let package_path = package.path.strip_prefix(&self.workspace.root)?;
+			let package_language = match package.config {
+				PackageConfig::Crate(_) => "Rust",
+				PackageConfig::PyProject(_) => "Python",
+			};
 			for (file, finding) in findings.iter() {
 				let source_path = file.strip_prefix(&self.workspace.root)?;
 				log::error!(
-					"crate: {}, crate_path: {}, source: {}, name: {:?}, desc: {}",
-					krate.name,
-					krate_path.display(),
+					"package: {}, package_path: {}, package_language: {}, source: {}, name: {:?}, desc: {}",
+					package.name,
+					package_path.display(),
+					package_language,
 					source_path.display(),
 					finding,
 					finding
@@ -153,28 +172,39 @@ impl<'work> Findings<'work> {
 	}
 }
 
-/// Types of issues crates can have.
+/// Types of issues packages can have.
 #[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-enum CrateIssues {
-	/// The crate has authors when it shouldn't.
+enum PackageIssues {
+	/// The package has authors when it shouldn't.
 	HasAuthors,
+	/// The package has no authors when it should.
+	MissingAuthors,
 	/// The crate license is present.
 	LicensePresent,
+	/// The python project is missing license that duplicates the workspace license
+	NoDuplicateLicense,
 	/// The license config in the manifest is invalid.
 	LicenseInvalid,
 	/// Crate is using an edition other than 2021.
 	Not2021Edition,
+	/// Python project does not support Python version 3.10
+	NotPython3_10,
 }
 
-impl Display for CrateIssues {
+impl Display for PackageIssues {
 	fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-		use CrateIssues::*;
+		use PackageIssues::*;
 
 		let msg = match self {
-			HasAuthors => "must not have an authors field in 'Cargo.toml'",
+			HasAuthors => "must not have an authors field in the 'Cargo.toml' file",
+			MissingAuthors => "must have an authors filed in the 'pyproject.toml' file",
 			LicensePresent => "must not have a 'LICENSE.md' file",
+			NoDuplicateLicense => {
+				"must have a single license file that matches the Hipcheck license file"
+			}
 			LicenseInvalid => "license must be set to `'Apache-2.0'`",
 			Not2021Edition => "edition must be set to '2021' in 'Cargo.toml'",
+			NotPython3_10 => "python projects must support Python version 3.10",
 		};
 
 		write!(f, "{}", msg)
@@ -206,14 +236,27 @@ impl Display for ConfigIssues {
 	}
 }
 
-/// Perform validation of a crate, producing findings.
-fn validate_crate(krate: &Crate) -> CrateFindingsSet {
-	use CrateIssues::*;
+/// Perform validation of a package, producing findings.
+fn validate_package(package: &Package) -> PackageFindingsSet {
+	match &package.config {
+		PackageConfig::Crate(_) => validate_crate(package),
+		PackageConfig::PyProject(_) => validate_pyproject(package),
+	}
+}
+
+/// Perform validation of a Rust crate manifest, producing findings.
+fn validate_crate(krate: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	// Panic: We only call this function if the package is a Cargo crate
+	let PackageConfig::Crate(manifest) = &krate.config else {
+		panic!()
+	};
 
 	let mut findings = BTreeSet::new();
 
 	log::info!("validating crate '{}' doesn't specify authors", krate.name);
-	if crate_has_authors(krate) {
+	if crate_has_authors(manifest) {
 		findings.insert(HasAuthors);
 	}
 
@@ -222,19 +265,19 @@ fn validate_crate(krate: &Crate) -> CrateFindingsSet {
 		krate.name
 	);
 	if crate_license_file_present(krate) {
-		findings.insert(LicensePresent);
+		findings.insert(PackageIssues::LicensePresent);
 	}
 
 	log::info!(
 		"validating crate '{}' specifies the correct license",
 		krate.name
 	);
-	if crate_license_invalid(krate) {
+	if crate_license_invalid(manifest) {
 		findings.insert(LicenseInvalid);
 	}
 
 	log::info!("validating crate '{}' uses the correct edition", krate.name);
-	if crate_uses_wrong_edition(krate) {
+	if crate_uses_wrong_edition(manifest) {
 		findings.insert(Not2021Edition);
 	}
 
@@ -242,26 +285,27 @@ fn validate_crate(krate: &Crate) -> CrateFindingsSet {
 }
 
 /// Check if the 'Cargo.toml' file has an authors field.
-fn crate_has_authors(krate: &Crate) -> bool {
-	krate.manifest.package.authors.is_some()
+fn crate_has_authors(manifest: &CrateManifest) -> bool {
+	manifest.package.authors.is_some()
 }
 
 /// Check if the crate has a 'LICENSE.md' file.
-fn crate_license_file_present(krate: &Crate) -> bool {
-	krate.license_path.exists()
+fn crate_license_file_present(krate: &Package) -> bool {
+	// Panic: For a Rust crate, there will always be a single license path
+	krate.license_paths.as_ref().unwrap()[0].exists()
 }
 
 /// Check if the crate license isn't the expected one.
-fn crate_license_invalid(krate: &Crate) -> bool {
-	match &krate.manifest.package.license {
+fn crate_license_invalid(manifest: &CrateManifest) -> bool {
+	match &manifest.package.license {
 		Some(license) => license.as_str() != "Apache-2.0",
 		None => true,
 	}
 }
 
-fn crate_uses_wrong_edition(krate: &Crate) -> bool {
-	krate
-		.manifest
+/// Check if the `Cargo.toml` file specifies the wrong edition of Rust
+fn crate_uses_wrong_edition(manifest: &CrateManifest) -> bool {
+	manifest
 		.package
 		.edition
 		.as_ref()
@@ -269,6 +313,106 @@ fn crate_uses_wrong_edition(krate: &Crate) -> bool {
 		.unwrap_or(true)
 }
 
+/// Perform validation of a python project, producing findings.
+fn validate_pyproject(pyproject: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	let mut findings = BTreeSet::new();
+
+	// Panic: We only call this function if the package is a Python project
+	let PackageConfig::PyProject(py_config) = &pyproject.config else {
+		panic!()
+	};
+
+	log::info!(
+		"validating Python project '{}' specifies authors",
+		pyproject.name
+	);
+	if pyproject_missing_authors(py_config) {
+		findings.insert(MissingAuthors);
+	}
+
+	log::info!(
+		"validating Python project '{}' doesn't specify license_file",
+		pyproject.name
+	);
+	if pyproject_license_file_missing(pyproject) {
+		findings.insert(PackageIssues::NoDuplicateLicense);
+	}
+
+	log::info!(
+		"validating Python project '{}' specifies the correct license",
+		pyproject.name
+	);
+	if pyproject_license_invalid(py_config) {
+		findings.insert(LicenseInvalid);
+	}
+
+	log::info!(
+		"validating Python project '{}' uses the correct version",
+		pyproject.name
+	);
+	if pyproject_uses_wrong_version(py_config) {
+		findings.insert(NotPython3_10);
+	}
+
+	findings
+}
+
+/// Check if the 'pyproject.toml' file has an authors field.
+fn pyproject_missing_authors(py_config: &PyProjectConfiguration) -> bool {
+	py_config.authors.is_none()
+}
+
+/// Check if the Python project is missing a 'LICENSE.md' file or has one that is not identical to the Hipcheck license.
+fn pyproject_license_file_missing(pyproject: &Package) -> bool {
+	// Check that license paths were specified in the pyproject.toml
+	let Some(license_paths) = &pyproject.license_paths else {
+		return true;
+	};
+	// Check that only one license path was specified
+	if license_paths.len() != 1 {
+		return true;
+	}
+
+	// Check that the Python project license file exists and read its contents if it does
+	let license_path = &license_paths[0];
+	let Ok(python_license) = fs::read(license_path) else {
+		return true;
+	};
+
+	// Read the workspace license file
+
+	// Panic: Safe to unwrap because we have already called this function without error when finding the license path
+	let root = workspace::root().unwrap();
+	let hipcheck_license_path = pathbuf![&root, "LICENSE"];
+	// Panic: The Hipcheck 'LICENSE' file should exist and be readable
+	let hipcheck_license =
+		fs::read(hipcheck_license_path).expect("Unable to read Hipcheck license file");
+
+	// Compare the license files
+	python_license != hipcheck_license
+}
+
+/// Check if the Python project license isn't the expected one.
+fn pyproject_license_invalid(py_config: &PyProjectConfiguration) -> bool {
+	match &py_config.license {
+		Some(License::Spdx(license)) => license != "Apache-2.0",
+		// Currently we do not check for unexpected licenses if the license information is anything other than an SPDX string
+		_ => true,
+	}
+}
+
+/// Check if the 'pyproject.toml' file does not indicate support for the correct version of Python
+fn pyproject_uses_wrong_version(py_config: &PyProjectConfiguration) -> bool {
+	let version = Version::from_str("3.10").unwrap();
+
+	match &py_config.version_specs {
+		Some(version_specs) => !version_specs.contains(&version),
+		None => true,
+	}
+}
+
 /// Perform validation of a configuration file, producing findings.
 fn validate_config(config: &Path) -> ConfigFindingsSet {
 	use ConfigIssues::*;
@@ -315,15 +459,21 @@ impl Display for SourceIssues {
 	}
 }
 
-/// Perform validation of the source files of a crate.
-fn validate_sources(krate: &Crate) -> SourceFindingsSet {
-	let pattern = format!("{}/**/*.rs", krate.path.display());
+fn validate_sources(package: &Package) -> SourceFindingsSet {
+	let mut findings = Vec::new();
+
+	// Get paths to *.rs files
+	let rust_pattern = format!("{}/**/*.rs", package.path.display());
 	// PANIC SAFETY: We should always be able to parse the globbing pattern.
-	let globber = glob(&pattern).expect("failed to parse globbing pattern");
+	let rust_globber = glob(&rust_pattern).expect("failed to parse globbing pattern");
 
-	let mut findings = Vec::new();
+	// Get paths to *.py files
+	let py_pattern = format!("{}/**/*.py", package.path.display());
+	// PANIC SAFETY: We should always be able to parse the globbing pattern.
+	let py_globber = glob(&py_pattern).expect("failed to parse globbing pattern");
 
-	for path_result in globber {
+	// Validate files of either extension
+	for path_result in rust_globber.chain(py_globber) {
 		match path_result {
 			Ok(path) => {
 				log::info!(
@@ -357,7 +507,10 @@ fn source_missing_license_comment(source_path: &Path) -> bool {
 	let reader = BufReader::new(file);
 
 	match reader.lines().next() {
-		Some(Ok(line)) => str::trim(&line) != "// SPDX-License-Identifier: Apache-2.0",
+		Some(Ok(line)) => {
+			str::trim(&line) != "// SPDX-License-Identifier: Apache-2.0"
+				&& str::trim(&line) != "# SPDX-License-Identifier: Apache-2.0"
+		}
 		// Treat any inability to read a line or the lack of lines as an indicator that the
 		// license comment is missing.
 		_ => true,
@@ -367,8 +520,8 @@ fn source_missing_license_comment(source_path: &Path) -> bool {
 /// Owns all the crates in the workspace.
 #[derive(Debug)]
 struct Workspace {
-	/// The crates in the workspace.
-	crates: Vec<Crate>,
+	/// The packages in the workspace
+	packages: Vec<Package>,
 	/// The root path of the workspace.
 	root: PathBuf,
 	/// Paths to configuration files.
@@ -380,40 +533,48 @@ impl Workspace {
 	fn resolve() -> Result<Workspace> {
 		let root = workspace::root()?;
 
-		let crates = {
+		// Get Rust crates from workspace Cargo.toml
+		let mut packages = {
 			let manifest_path = pathbuf![&root, "Cargo.toml"];
 			read_toml::<&Path, WorkspaceManifest>(&manifest_path)?.crates(&root)?
 		};
 
+		// Manually add our single Python project
+		let python_sdk_path = pathbuf![&root, "sdk", "python"];
+		let python_sdk = Package::python_at_path(python_sdk_path)?;
+
+		let mut pyprojects = vec![python_sdk];
+		packages.append(&mut pyprojects);
+
 		let configs = {
 			let config_dir = pathbuf![&root, "config"];
 			resolve_configs(&config_dir)?
 		};
 
 		Ok(Workspace {
-			crates,
+			packages,
 			root,
 			configs,
 		})
 	}
 }
 
-/// A single crate.
+/// A single package (here meaning a "unit of reuse"), such as a Rust crate
 #[derive(Debug)]
-pub struct Crate {
-	/// The name of the crate (like "hc_core")
+pub struct Package {
+	/// The name of the package (like "hc_core")
 	name: String,
-	/// The path to the crate
+	/// The path to the package
 	path: PathBuf,
-	/// Data from the crate manifest ('Cargo.toml')
-	manifest: CrateManifest,
-	/// The path to the license file, which may or may not be present.
-	license_path: PathBuf,
+	/// Data from the package configuration file (e.g. 'Cargo.toml', 'pyproject.toml')
+	config: PackageConfig,
+	/// The paths to any license files, which may or may not be present.
+	license_paths: Option<Vec<PathBuf>>,
 }
 
-impl Crate {
-	/// Identify information for the crate at the given path.
-	fn at_path(path: PathBuf) -> Result<Crate> {
+impl Package {
+	/// Identify information for the Rust crate at the given path.
+	fn crate_at_path(path: PathBuf) -> Result<Package> {
 		let name = path
 			.file_name()
 			.ok_or_else(|| anyhow!("missing crate name"))?
@@ -424,18 +585,62 @@ impl Crate {
 			let manifest_path = pathbuf![&path, "Cargo.toml"];
 			read_toml::<&Path, CrateManifest>(&manifest_path)?
 		};
+		let config = PackageConfig::Crate(manifest);
+
+		let license_paths = Some(vec![pathbuf![&path, "LICENSE.md"]]);
+
+		Ok(Package {
+			name,
+			path,
+			config,
+			license_paths,
+		})
+	}
+
+	/// Identify information for the Python project at the given path
+	fn python_at_path(path: PathBuf) -> Result<Package> {
+		let name = path
+			.file_name()
+			.ok_or_else(|| anyhow!("missing crate name"))?
+			.to_string_lossy()
+			.into_owned();
+
+		let project = {
+			let pyproject_path = pathbuf![&path, "pyproject.toml"];
+			read_pyproject_toml::<&Path>(&pyproject_path)?
+		};
+
+		let authors = project.authors;
+		let license = project.license;
+		let version_specs = project.requires_python;
+
+		let py_config = PyProjectConfiguration {
+			authors,
+			license,
+			version_specs,
+		};
+		let config = PackageConfig::PyProject(py_config);
 
-		let license_path = pathbuf![&path, "LICENSE.md"];
+		let license_files = project.license_files;
+		let license_paths =
+			license_files.map(|l| l.into_iter().map(|f| pathbuf![&path, &f]).collect());
 
-		Ok(Crate {
+		Ok(Package {
 			name,
 			path,
-			manifest,
-			license_path,
+			config,
+			license_paths,
 		})
 	}
 }
 
+/// Data from a package configuration file
+#[derive(Debug)]
+enum PackageConfig {
+	Crate(CrateManifest),
+	PyProject(PyProjectConfiguration),
+}
+
 /// The direct representation of the top-level 'Cargo.toml' file.
 #[derive(Debug, Deserialize)]
 struct WorkspaceManifest {
@@ -445,7 +650,7 @@ struct WorkspaceManifest {
 
 impl WorkspaceManifest {
 	/// Get the crates defined by the workspace manifest.
-	fn crates(&self, root: &Path) -> Result<Vec<Crate>> {
+	fn crates(&self, root: &Path) -> Result<Vec<Package>> {
 		let mut entries = Vec::new();
 
 		for member in &self.workspace.members {
@@ -454,7 +659,7 @@ impl WorkspaceManifest {
 
 			// For each path it expands to, fill out the crate info.
 			for path in glob(&to_glob)? {
-				entries.push(Crate::at_path(path?)?);
+				entries.push(Package::crate_at_path(path?)?);
 			}
 		}
 
@@ -487,6 +692,17 @@ struct CrateManifestPackage {
 	edition: Option<String>,
 }
 
+/// The configuration file for individual python projects.
+#[derive(Debug, Deserialize)]
+struct PyProjectConfiguration {
+	/// The author information.
+	authors: Option<Vec<Contact>>,
+	/// The license of the project.
+	license: Option<License>,
+	/// The version speicifiers of Python that the project requires.
+	version_specs: Option<VersionSpecifiers>,
+}
+
 /// Identify all the Hipcheck configuration files in the workspace.
 fn resolve_configs(dir: &Path) -> Result<Vec<PathBuf>> {
 	let to_glob = format!("{}/*.toml", dir.display());
@@ -508,6 +724,18 @@ fn read_toml<P: AsRef<Path>, T: DeserializeOwned>(path: P) -> Result<T> {
 		.with_context(|| format!("failed to read as TOML '{}'", path.display()))
 }
 
+/// Read file to a struct that can be deserialized from pyproject.toml format.
+fn read_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Project> {
+	let path = path.as_ref();
+	let contents = read_string(path)?;
+	let pyproject = PyProjectToml::new(&contents)
+		.with_context(|| format!("failed to read as pyproject.toml '{}'", path.display()))?;
+	pyproject.project.ok_or(anyhow!(
+		"pyproject.toml missing project information '{}'",
+		path.display()
+	))
+}
+
 /// Read a file to a string.
 fn read_string<P: AsRef<Path>>(path: P) -> Result<String> {
 	fn inner(path: &Path) -> Result<String> {


Reviews:
user: login: j-lanson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:35:28Z
user: login: j-lanson
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:36:06Z
user: login: alilleybrinker
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:51:20Z
user: login: alilleybrinker
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:51:58Z
user: login: j-lanson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:53:09Z
user: login: alilleybrinker
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:58:32Z
user: login: j-lanson
body: See discussion
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T20:00:13Z
user: login: j-lanson
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:30:21Z
user: login: mchernicoff
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:58:21Z
user: login: j-lanson
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-12T19:01:10Z

Review comments:
diff_hunk: @@ -206,69 +221,152 @@ impl Display for ConfigIssues {
 	}
 }
 
-/// Perform validation of a crate, producing findings.
-fn validate_crate(krate: &Crate) -> CrateFindingsSet {
-	use CrateIssues::*;
+/// Perform validation of a package, producing findings.
+fn validate_package(package: &Package) -> PackageFindingsSet {
+	match &package.config {
+		PackageConfig::Crate(_) => validate_crate(package),
+		PackageConfig::PyProject(_) => validate_pyproject(package),
+	}
+}
+
+/// Perform validation of a Rust crate manifest, producing findings.
+fn validate_crate(krate: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	// Panic: We only call this function if the package is a Cargo crate
+	let PackageConfig::Crate(manifest) = &krate.config else {
+		panic!()
+	};
 
 	let mut findings = BTreeSet::new();
 
 	log::info!("validating crate '{}' doesn't specify authors", krate.name);
-	if crate_has_authors(krate) {
+	if crate_has_authors(manifest) {
 		findings.insert(HasAuthors);
 	}
 
 	log::info!(
 		"validating crate '{}' doesn't specify license_file",
 		krate.name
 	);
-	if crate_license_file_present(krate) {
-		findings.insert(LicensePresent);
+	if package_license_file_present(krate) {
+		findings.insert(PackageIssues::LicensePresent);
 	}
 
 	log::info!(
 		"validating crate '{}' specifies the correct license",
 		krate.name
 	);
-	if crate_license_invalid(krate) {
+	if crate_license_invalid(manifest) {
 		findings.insert(LicenseInvalid);
 	}
 
 	log::info!("validating crate '{}' uses the correct edition", krate.name);
-	if crate_uses_wrong_edition(krate) {
+	if crate_uses_wrong_edition(manifest) {
 		findings.insert(Not2021Edition);
 	}
 
 	findings
 }
 
 /// Check if the 'Cargo.toml' file has an authors field.
-fn crate_has_authors(krate: &Crate) -> bool {
-	krate.manifest.package.authors.is_some()
-}
-
-/// Check if the crate has a 'LICENSE.md' file.
-fn crate_license_file_present(krate: &Crate) -> bool {
-	krate.license_path.exists()
+fn crate_has_authors(manifest: &CrateManifest) -> bool {
+	manifest.package.authors.is_some()
 }
 
 /// Check if the crate license isn't the expected one.
-fn crate_license_invalid(krate: &Crate) -> bool {
-	match &krate.manifest.package.license {
+fn crate_license_invalid(manifest: &CrateManifest) -> bool {
+	match &manifest.package.license {
 		Some(license) => license.as_str() != "Apache-2.0",
 		None => true,
 	}
 }
 
-fn crate_uses_wrong_edition(krate: &Crate) -> bool {
-	krate
-		.manifest
+/// Check if the `Cargo.toml` file specifies the wrong edition of Rust
+fn crate_uses_wrong_edition(manifest: &CrateManifest) -> bool {
+	manifest
 		.package
 		.edition
 		.as_ref()
 		.map(|e| e != "2021")
 		.unwrap_or(true)
 }
 
+/// Perform validation of a python project, producing findings.
+fn validate_pyproject(pyproject: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	let mut findings = BTreeSet::new();
+
+	// Panic: We only call this function if the package is a Python project
+	let PackageConfig::PyProject(py_config) = &pyproject.config else {
+		panic!()
+	};
+
+	log::info!(
+		"validating Python project '{}' doesn't specify authors",
+		pyproject.name
+	);
+	if pyproject_has_authors(py_config) {
+		findings.insert(HasAuthors);
+	}
+
+	log::info!(
+		"validating Python project '{}' doesn't specify license_file",
+		pyproject.name
+	);
+	if package_license_file_present(pyproject) {
+		findings.insert(PackageIssues::LicensePresent);
+	}
path: xtask/src/task/validate.rs
user: login: j-lanson
body: My lack of understanding about how this xtask works - isn't having authors and a license file a good thing? or is the idea that we only want those things at the "top level"
created_at: 2025-03-11T19:35:28Z
author_association: COLLABORATOR
diff_hunk: @@ -206,69 +221,152 @@ impl Display for ConfigIssues {
 	}
 }
 
-/// Perform validation of a crate, producing findings.
-fn validate_crate(krate: &Crate) -> CrateFindingsSet {
-	use CrateIssues::*;
+/// Perform validation of a package, producing findings.
+fn validate_package(package: &Package) -> PackageFindingsSet {
+	match &package.config {
+		PackageConfig::Crate(_) => validate_crate(package),
+		PackageConfig::PyProject(_) => validate_pyproject(package),
+	}
+}
+
+/// Perform validation of a Rust crate manifest, producing findings.
+fn validate_crate(krate: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	// Panic: We only call this function if the package is a Cargo crate
+	let PackageConfig::Crate(manifest) = &krate.config else {
+		panic!()
+	};
 
 	let mut findings = BTreeSet::new();
 
 	log::info!("validating crate '{}' doesn't specify authors", krate.name);
-	if crate_has_authors(krate) {
+	if crate_has_authors(manifest) {
 		findings.insert(HasAuthors);
 	}
 
 	log::info!(
 		"validating crate '{}' doesn't specify license_file",
 		krate.name
 	);
-	if crate_license_file_present(krate) {
-		findings.insert(LicensePresent);
+	if package_license_file_present(krate) {
+		findings.insert(PackageIssues::LicensePresent);
 	}
 
 	log::info!(
 		"validating crate '{}' specifies the correct license",
 		krate.name
 	);
-	if crate_license_invalid(krate) {
+	if crate_license_invalid(manifest) {
 		findings.insert(LicenseInvalid);
 	}
 
 	log::info!("validating crate '{}' uses the correct edition", krate.name);
-	if crate_uses_wrong_edition(krate) {
+	if crate_uses_wrong_edition(manifest) {
 		findings.insert(Not2021Edition);
 	}
 
 	findings
 }
 
 /// Check if the 'Cargo.toml' file has an authors field.
-fn crate_has_authors(krate: &Crate) -> bool {
-	krate.manifest.package.authors.is_some()
-}
-
-/// Check if the crate has a 'LICENSE.md' file.
-fn crate_license_file_present(krate: &Crate) -> bool {
-	krate.license_path.exists()
+fn crate_has_authors(manifest: &CrateManifest) -> bool {
+	manifest.package.authors.is_some()
 }
 
 /// Check if the crate license isn't the expected one.
-fn crate_license_invalid(krate: &Crate) -> bool {
-	match &krate.manifest.package.license {
+fn crate_license_invalid(manifest: &CrateManifest) -> bool {
+	match &manifest.package.license {
 		Some(license) => license.as_str() != "Apache-2.0",
 		None => true,
 	}
 }
 
-fn crate_uses_wrong_edition(krate: &Crate) -> bool {
-	krate
-		.manifest
+/// Check if the `Cargo.toml` file specifies the wrong edition of Rust
+fn crate_uses_wrong_edition(manifest: &CrateManifest) -> bool {
+	manifest
 		.package
 		.edition
 		.as_ref()
 		.map(|e| e != "2021")
 		.unwrap_or(true)
 }
 
+/// Perform validation of a python project, producing findings.
+fn validate_pyproject(pyproject: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	let mut findings = BTreeSet::new();
+
+	// Panic: We only call this function if the package is a Python project
+	let PackageConfig::PyProject(py_config) = &pyproject.config else {
+		panic!()
+	};
+
+	log::info!(
+		"validating Python project '{}' doesn't specify authors",
+		pyproject.name
+	);
+	if pyproject_has_authors(py_config) {
+		findings.insert(HasAuthors);
+	}
+
+	log::info!(
+		"validating Python project '{}' doesn't specify license_file",
+		pyproject.name
+	);
+	if package_license_file_present(pyproject) {
+		findings.insert(PackageIssues::LicensePresent);
+	}
path: xtask/src/task/validate.rs
user: login: alilleybrinker
body: Yeah, we want to just delegate to the workspace and have crates inherit from it.
created_at: 2025-03-11T19:51:20Z
author_association: COLLABORATOR
diff_hunk: @@ -206,69 +221,152 @@ impl Display for ConfigIssues {
 	}
 }
 
-/// Perform validation of a crate, producing findings.
-fn validate_crate(krate: &Crate) -> CrateFindingsSet {
-	use CrateIssues::*;
+/// Perform validation of a package, producing findings.
+fn validate_package(package: &Package) -> PackageFindingsSet {
+	match &package.config {
+		PackageConfig::Crate(_) => validate_crate(package),
+		PackageConfig::PyProject(_) => validate_pyproject(package),
+	}
+}
+
+/// Perform validation of a Rust crate manifest, producing findings.
+fn validate_crate(krate: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	// Panic: We only call this function if the package is a Cargo crate
+	let PackageConfig::Crate(manifest) = &krate.config else {
+		panic!()
+	};
 
 	let mut findings = BTreeSet::new();
 
 	log::info!("validating crate '{}' doesn't specify authors", krate.name);
-	if crate_has_authors(krate) {
+	if crate_has_authors(manifest) {
 		findings.insert(HasAuthors);
 	}
 
 	log::info!(
 		"validating crate '{}' doesn't specify license_file",
 		krate.name
 	);
-	if crate_license_file_present(krate) {
-		findings.insert(LicensePresent);
+	if package_license_file_present(krate) {
+		findings.insert(PackageIssues::LicensePresent);
 	}
 
 	log::info!(
 		"validating crate '{}' specifies the correct license",
 		krate.name
 	);
-	if crate_license_invalid(krate) {
+	if crate_license_invalid(manifest) {
 		findings.insert(LicenseInvalid);
 	}
 
 	log::info!("validating crate '{}' uses the correct edition", krate.name);
-	if crate_uses_wrong_edition(krate) {
+	if crate_uses_wrong_edition(manifest) {
 		findings.insert(Not2021Edition);
 	}
 
 	findings
 }
 
 /// Check if the 'Cargo.toml' file has an authors field.
-fn crate_has_authors(krate: &Crate) -> bool {
-	krate.manifest.package.authors.is_some()
-}
-
-/// Check if the crate has a 'LICENSE.md' file.
-fn crate_license_file_present(krate: &Crate) -> bool {
-	krate.license_path.exists()
+fn crate_has_authors(manifest: &CrateManifest) -> bool {
+	manifest.package.authors.is_some()
 }
 
 /// Check if the crate license isn't the expected one.
-fn crate_license_invalid(krate: &Crate) -> bool {
-	match &krate.manifest.package.license {
+fn crate_license_invalid(manifest: &CrateManifest) -> bool {
+	match &manifest.package.license {
 		Some(license) => license.as_str() != "Apache-2.0",
 		None => true,
 	}
 }
 
-fn crate_uses_wrong_edition(krate: &Crate) -> bool {
-	krate
-		.manifest
+/// Check if the `Cargo.toml` file specifies the wrong edition of Rust
+fn crate_uses_wrong_edition(manifest: &CrateManifest) -> bool {
+	manifest
 		.package
 		.edition
 		.as_ref()
 		.map(|e| e != "2021")
 		.unwrap_or(true)
 }
 
+/// Perform validation of a python project, producing findings.
+fn validate_pyproject(pyproject: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	let mut findings = BTreeSet::new();
+
+	// Panic: We only call this function if the package is a Python project
+	let PackageConfig::PyProject(py_config) = &pyproject.config else {
+		panic!()
+	};
+
+	log::info!(
+		"validating Python project '{}' doesn't specify authors",
+		pyproject.name
+	);
+	if pyproject_has_authors(py_config) {
+		findings.insert(HasAuthors);
+	}
+
+	log::info!(
+		"validating Python project '{}' doesn't specify license_file",
+		pyproject.name
+	);
+	if package_license_file_present(pyproject) {
+		findings.insert(PackageIssues::LicensePresent);
+	}
path: xtask/src/task/validate.rs
user: login: j-lanson
body: Does that still make sense for checking license info / author info for the Python SDK?
created_at: 2025-03-11T19:53:09Z
author_association: COLLABORATOR
diff_hunk: @@ -206,69 +221,152 @@ impl Display for ConfigIssues {
 	}
 }
 
-/// Perform validation of a crate, producing findings.
-fn validate_crate(krate: &Crate) -> CrateFindingsSet {
-	use CrateIssues::*;
+/// Perform validation of a package, producing findings.
+fn validate_package(package: &Package) -> PackageFindingsSet {
+	match &package.config {
+		PackageConfig::Crate(_) => validate_crate(package),
+		PackageConfig::PyProject(_) => validate_pyproject(package),
+	}
+}
+
+/// Perform validation of a Rust crate manifest, producing findings.
+fn validate_crate(krate: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	// Panic: We only call this function if the package is a Cargo crate
+	let PackageConfig::Crate(manifest) = &krate.config else {
+		panic!()
+	};
 
 	let mut findings = BTreeSet::new();
 
 	log::info!("validating crate '{}' doesn't specify authors", krate.name);
-	if crate_has_authors(krate) {
+	if crate_has_authors(manifest) {
 		findings.insert(HasAuthors);
 	}
 
 	log::info!(
 		"validating crate '{}' doesn't specify license_file",
 		krate.name
 	);
-	if crate_license_file_present(krate) {
-		findings.insert(LicensePresent);
+	if package_license_file_present(krate) {
+		findings.insert(PackageIssues::LicensePresent);
 	}
 
 	log::info!(
 		"validating crate '{}' specifies the correct license",
 		krate.name
 	);
-	if crate_license_invalid(krate) {
+	if crate_license_invalid(manifest) {
 		findings.insert(LicenseInvalid);
 	}
 
 	log::info!("validating crate '{}' uses the correct edition", krate.name);
-	if crate_uses_wrong_edition(krate) {
+	if crate_uses_wrong_edition(manifest) {
 		findings.insert(Not2021Edition);
 	}
 
 	findings
 }
 
 /// Check if the 'Cargo.toml' file has an authors field.
-fn crate_has_authors(krate: &Crate) -> bool {
-	krate.manifest.package.authors.is_some()
-}
-
-/// Check if the crate has a 'LICENSE.md' file.
-fn crate_license_file_present(krate: &Crate) -> bool {
-	krate.license_path.exists()
+fn crate_has_authors(manifest: &CrateManifest) -> bool {
+	manifest.package.authors.is_some()
 }
 
 /// Check if the crate license isn't the expected one.
-fn crate_license_invalid(krate: &Crate) -> bool {
-	match &krate.manifest.package.license {
+fn crate_license_invalid(manifest: &CrateManifest) -> bool {
+	match &manifest.package.license {
 		Some(license) => license.as_str() != "Apache-2.0",
 		None => true,
 	}
 }
 
-fn crate_uses_wrong_edition(krate: &Crate) -> bool {
-	krate
-		.manifest
+/// Check if the `Cargo.toml` file specifies the wrong edition of Rust
+fn crate_uses_wrong_edition(manifest: &CrateManifest) -> bool {
+	manifest
 		.package
 		.edition
 		.as_ref()
 		.map(|e| e != "2021")
 		.unwrap_or(true)
 }
 
+/// Perform validation of a python project, producing findings.
+fn validate_pyproject(pyproject: &Package) -> PackageFindingsSet {
+	use PackageIssues::*;
+
+	let mut findings = BTreeSet::new();
+
+	// Panic: We only call this function if the package is a Python project
+	let PackageConfig::PyProject(py_config) = &pyproject.config else {
+		panic!()
+	};
+
+	log::info!(
+		"validating Python project '{}' doesn't specify authors",
+		pyproject.name
+	);
+	if pyproject_has_authors(py_config) {
+		findings.insert(HasAuthors);
+	}
+
+	log::info!(
+		"validating Python project '{}' doesn't specify license_file",
+		pyproject.name
+	);
+	if package_license_file_present(pyproject) {
+		findings.insert(PackageIssues::LicensePresent);
+	}
path: xtask/src/task/validate.rs
user: login: alilleybrinker
body: Hm, fair point. No, I think for Python since it can't inherit from the Cargo workspace, we *do* want license information to be present in the package manifest.
created_at: 2025-03-11T19:58:32Z
author_association: COLLABORATOR
diff_hunk: @@ -2,23 +2,27 @@
 
 //! Utilities for extracting repository info from CycloneDX documents.
 
-use super::{
-	pm::{extract_repo_for_maven, extract_repo_for_npm, extract_repo_for_pypi},
-	purl::parse_purl,
-	TargetType,
-};
+use super::{pm::extract_repo_for_maven, purl::parse_purl, TargetType};
 use crate::{
 	error::{Context as _, Result},
 	hc_error,
+	target::types::*,
 };
 use cyclonedx_bom::prelude::*;
 use packageurl::PackageUrl;
 use std::str::FromStr;
 use url::Url;
 
+/// return type options for extract_download_url. The target type can be a Url or Package
+#[derive(Debug)]
+pub enum BomTarget {
+	Url(Url),
+	Package(Package),
+}
+
path: hipcheck/src/target/cyclone_dx.rs
user: login: j-lanson
body: I'm confused why Nino's work is showing up in this PR diff?
created_at: 2025-03-12T15:02:24Z
author_association: COLLABORATOR
diff_hunk: @@ -424,18 +574,60 @@ impl Crate {
 			let manifest_path = pathbuf![&path, "Cargo.toml"];
 			read_toml::<&Path, CrateManifest>(&manifest_path)?
 		};
+		let config = PackageConfig::Crate(manifest);
+
+		let license_path = pathbuf![&path, "LICENSE.md"];
+
+		Ok(Package {
+			name,
+			path,
+			config,
+			license_path,
+		})
+	}
+
+	/// Identify information for the Python project at the given path
+	fn python_at_path(path: PathBuf) -> Result<Package> {
+		let name = path
+			.file_name()
+			.ok_or_else(|| anyhow!("missing crate name"))?
+			.to_string_lossy()
+			.into_owned();
+
+		let project = {
+			let pyproject_path = pathbuf![&path, "pyproject.toml"];
+			read_pyproject_toml::<&Path>(&pyproject_path)?
+		};
+
+		let authors = project.authors;
+		let license = project.license;
+		let version_specs = project.requires_python;
+
+		let py_config = PyProjectConfiguration {
+			authors,
+			license,
+			version_specs,
+		};
+		let config = PackageConfig::PyProject(py_config);
 
 		let license_path = pathbuf![&path, "LICENSE.md"];
path: xtask/src/task/validate.rs
user: login: j-lanson
body: Rather than assume the file is `LICENSE.md`, I would suggest using `project.license_files`

created_at: 2025-03-12T15:09:42Z
author_association: COLLABORATOR
diff_hunk: @@ -2,23 +2,27 @@
 
 //! Utilities for extracting repository info from CycloneDX documents.
 
-use super::{
-	pm::{extract_repo_for_maven, extract_repo_for_npm, extract_repo_for_pypi},
-	purl::parse_purl,
-	TargetType,
-};
+use super::{pm::extract_repo_for_maven, purl::parse_purl, TargetType};
 use crate::{
 	error::{Context as _, Result},
 	hc_error,
+	target::types::*,
 };
 use cyclonedx_bom::prelude::*;
 use packageurl::PackageUrl;
 use std::str::FromStr;
 use url::Url;
 
+/// return type options for extract_download_url. The target type can be a Url or Package
+#[derive(Debug)]
+pub enum BomTarget {
+	Url(Url),
+	Package(Package),
+}
+
path: hipcheck/src/target/cyclone_dx.rs
user: login: mchernicoff
body: I might have messed up something with the rebase. Will investigate.
created_at: 2025-03-12T15:58:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: mchernicoff
created_at: 2025-03-11T20:00:42Z
body: I will flip the logic the other way around for Python. :)
author_association: CONTRIBUTOR
user: login: mchernicoff
created_at: 2025-03-12T00:35:05Z
body: Double checking: Do we want there to be a License file in the Python SDK? Right now we do check for a valid license in the pyproject.toml file, we just don't want there to be two entire License files in the workspace. Should I change that to make sure there is a second license file just for the Python SDK?
author_association: CONTRIBUTOR
user: login: j-lanson
created_at: 2025-03-12T12:53:56Z
body: I guess we can still check for a license file, there is in fact a field in the pyproject.toml to specify license files https://packaging.python.org/en/latest/guides/writing-pyproject-toml/

The reason is that the SDK will be available as source on PyPI, and we want to ensure that a LICENSE file is included with it.
author_association: COLLABORATOR