Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# FOSSA CLI Changelog

## Unreleased
## 3.16.8

- Poetry: Support PEP 621 `[project].dependencies` for Poetry 2.x projects. Production dependencies declared in the standard `[project]` section are now correctly detected alongside legacy `[tool.poetry.dependencies]`. ([#1683](https://github.com/fossas/fossa-cli/pull/1683))
- UV: Fix fatal parse error on uv.lock files containing editable/workspace packages with dynamic versions ([#1682](https://github.com/fossas/fossa-cli/pull/1682))
- Gradle: Add additional development and test configurations for common plugins ([#1684](https://github.com/fossas/fossa-cli/pull/1684))

Expand Down
6 changes: 1 addition & 5 deletions src/Strategy/Python/PDM/PdmLock.hs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Data.Text (Text)
import DepTypes (DepEnvironment (EnvDevelopment, EnvProduction), DepType (GitType, PipType, URLType, UnresolvedPathType), Dependency (..), VerConstraint (..), hydrateDepEnvs)
import Effect.Grapher (deep, direct, edge, evalGrapher, run)
import Graphing (Graphing, gmap)
import Strategy.Python.Util (Req (..))
import Strategy.Python.Util (Req (..), reqName)
import Toml.Schema qualified

-- | Represents pdm lock file.
Expand Down Expand Up @@ -131,10 +131,6 @@ toDependency prodReqs devReqs pkg =
isUrlReq (UrlReq{}) = True
isUrlReq _ = False

reqName :: Req -> Text
reqName (NameReq rname _ _ _) = rname
reqName (UrlReq rname _ _ _) = rname

buildGraph :: [Req] -> [Req] -> PdmLock -> Graphing Dependency
buildGraph prodReqs devReqs pdmLock = hydrateDepEnvs $
gmap (toDependency prodReqs devReqs) $
Expand Down
18 changes: 17 additions & 1 deletion src/Strategy/Python/Poetry/Common.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Strategy.Python.Poetry.PyProject (
PoetryDependency (..),
PyProject (..),
PyProjectBuildSystem (..),
PyProjectMetadata (..),
PyProjectPoetry (..),
PyProjectPoetryDetailedVersionDependency (..),
PyProjectPoetryGitDependency (..),
Expand All @@ -41,6 +42,7 @@ import Strategy.Python.Poetry.PyProject (
allPoetryNonProductionDeps,
toDependencyVersion,
)
import Strategy.Python.Util (reqToDependency)

-- | Gets build backend of pyproject.
getPoetryBuildBackend :: PyProject -> Maybe Text
Expand Down Expand Up @@ -126,13 +128,27 @@ pyProjectDeps project = filter notNamedPython $ map snd allDeps
Just (PyProjectTool{pyprojectPoetry}) -> maybe Map.empty dependencies pyprojectPoetry
_ -> mempty

-- PEP 621 [project].dependencies converted to Dependency with EnvProduction
pep621ProdDeps :: Map Text Dependency
pep621ProdDeps = case pyprojectProject project of
Just (PyProjectMetadata{pyprojectDependencies = Just reqs}) ->
Map.fromList $ map toNamedProdDep reqs
_ -> mempty
where
toNamedProdDep req =
let dep = (reqToDependency req){dependencyEnvironments = Set.singleton EnvProduction}
in (dependencyName dep, dep)

toDependency :: [DepEnvironment] -> Map Text PoetryDependency -> Map Text Dependency
toDependency depEnvs = Map.mapWithKey $ poetrytoDependency depEnvs

allDeps :: [(Text, Dependency)]
allDeps = Map.toList prodDeps ++ Map.toList devDeps
where
prodDeps = toDependency [EnvProduction] supportedProdDeps
-- Poetry-style prod deps take precedence; PEP 621 fills in anything missing.
-- [tool.poetry.dependencies] can carry richer metadata (explicit source, git refs, etc.)
-- that PEP 621's PEP 508 strings can't express, so it's strictly more informative.
prodDeps = toDependency [EnvProduction] supportedProdDeps `Map.union` pep621ProdDeps
devDeps = toDependency [EnvDevelopment] supportedDevDeps

-- | Gets Dependency from `PoetryDependency` and it's `DepEnvironment`.
Expand Down
32 changes: 24 additions & 8 deletions src/Strategy/Python/Poetry/PyProject.hs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ module Strategy.Python.Poetry.PyProject (
) where

import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
import Data.Foldable (asum)
import Data.Foldable (asum, foldl')
import Data.Functor (void)
import Data.Map (Map, unions)
import Data.Map (Map, insert, union, unions)
import Data.Maybe (fromMaybe)
import Data.String.Conversion (toString, toText)
import Data.Text (Text)
Expand All @@ -41,7 +41,7 @@ import DepTypes (
COr
),
)
import Strategy.Python.Util (Req)
import Strategy.Python.Util (Req (..), reqName)
import Text.Megaparsec (
Parsec,
empty,
Expand Down Expand Up @@ -188,12 +188,28 @@ instance Toml.Schema.FromValue PyProjectPoetryGroupDependencies where
PyProjectPoetryGroupDependencies
<$> Toml.Schema.pickKey [Toml.Schema.Key "dependencies" Toml.Schema.fromValue, Toml.Schema.Else (pure mempty)]

-- | Returns all production dependencies from a pyproject.toml.
-- Merges legacy [tool.poetry.dependencies] with PEP 621 [project].dependencies.
-- Poetry-style entries take precedence when a dep appears in both sections.
allPoetryProductionDeps :: PyProject -> Map Text PoetryDependency
allPoetryProductionDeps project = case pyprojectTool project of
Just (PyProjectTool{pyprojectPoetry}) -> case pyprojectPoetry of
Just (PyProjectPoetry{dependencies}) -> dependencies
_ -> mempty
_ -> mempty
allPoetryProductionDeps project = poetryDeps `union` pep621Deps
where
-- Legacy [tool.poetry.dependencies] — takes precedence in union
poetryDeps = case pyprojectTool project of
Just (PyProjectTool{pyprojectPoetry}) -> case pyprojectPoetry of
Just (PyProjectPoetry{dependencies}) -> dependencies
_ -> mempty
_ -> mempty

-- PEP 621 [project].dependencies — fallback for deps not in poetryDeps.
-- We use PoetryTextVersion "*" as a placeholder since the actual version
-- comes from the lock file. The key purpose is to register the package name
-- so it's recognized as a production dependency.
pep621Deps :: Map Text PoetryDependency
pep621Deps = case pyprojectProject project of
Just (PyProjectMetadata{pyprojectDependencies = Just reqs}) ->
foldl' (\acc req -> insert (reqName req) (PoetryTextVersion "*") acc) mempty reqs
_ -> mempty

allPoetryNonProductionDeps :: PyProject -> Map Text PoetryDependency
allPoetryNonProductionDeps project = unions [olderPoetryDevDeps, optionalDeps]
Expand Down
13 changes: 7 additions & 6 deletions src/Strategy/Python/Util.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module Strategy.Python.Util (
MarkerOp (..),
Operator (..),
Req (..),
reqName,
requirementParser,
reqToDependency,
toConstraint,
Expand Down Expand Up @@ -36,15 +37,11 @@ pkgToReq :: PythonPackage -> Req
pkgToReq p =
NameReq (pkgName p) Nothing (Just [Version OpEq (pkgVersion p)]) Nothing

depName :: Req -> Text
depName (NameReq nm _ _ _) = nm
depName (UrlReq nm _ _ _) = nm

reqToDependency :: Req -> Dependency
reqToDependency req =
Dependency
{ dependencyType = PipType
, dependencyName = depName req
, dependencyName = reqName req
, dependencyVersion = depVersion req
, dependencyLocations = []
, dependencyEnvironments = mempty
Expand Down Expand Up @@ -94,7 +91,7 @@ buildGraph maybePackages reqs = do
Nothing -> pure ()
where
findParent :: Text -> Maybe Req
findParent packageName = find (\r -> Text.toLower (depName r) == Text.toLower (packageName)) reqs
findParent packageName = find (\r -> Text.toLower (reqName r) == Text.toLower (packageName)) reqs

addChildren :: (Has (Grapher Req) sig m) => Req -> PythonPackage -> m ()
addChildren parent pkg = do
Expand Down Expand Up @@ -174,6 +171,10 @@ data Req
| UrlReq Text (Maybe [Text]) URI.URI (Maybe Marker) -- name, extras, ...
deriving (Eq, Ord, Show)

reqName :: Req -> Text
reqName (NameReq name _ _ _) = name
reqName (UrlReq name _ _ _) = name
Comment on lines +174 to +176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider consolidating with existing depName function.

The new reqName function (lines 178-180) has identical implementation to the existing depName function (lines 40-42):

-- depName at lines 40-42:
depName :: Req -> Text
depName (NameReq nm _ _ _) = nm
depName (UrlReq nm _ _ _) = nm

-- reqName at lines 178-180:
reqName :: Req -> Text
reqName (NameReq name _ _ _) = name
reqName (UrlReq name _ _ _) = name

Consider either:

  1. Exporting depName instead of introducing reqName, or
  2. Keeping reqName as the public API and making depName a local alias

This would eliminate code duplication within the same module.

♻️ Proposed refactor
 module Strategy.Python.Util (
   buildGraph,
   buildGraphSetupFile,
   Version (..),
   Marker (..),
   MarkerOp (..),
   Operator (..),
   Req (..),
-  reqName,
+  reqName,  -- exported name
   requirementParser,
   reqToDependency,
   toConstraint,
 ) where

...

-depName :: Req -> Text
-depName (NameReq nm _ _ _) = nm
-depName (UrlReq nm _ _ _) = nm
+-- | Extract the package name from a Req.
+reqName :: Req -> Text
+reqName (NameReq nm _ _ _) = nm
+reqName (UrlReq nm _ _ _) = nm
+
+-- | Alias for reqName, used internally.
+depName :: Req -> Text
+depName = reqName

...

--- Remove the duplicate definition at lines 178-180
-reqName :: Req -> Text
-reqName (NameReq name _ _ _) = name
-reqName (UrlReq name _ _ _) = name
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Strategy/Python/Util.hs` around lines 178 - 180, The functions depName
and reqName duplicate the same logic; remove the duplicate by consolidating
them: either export the existing depName as the public API and delete reqName,
or make depName a local alias to reqName (or vice‑versa) so only one
implementation remains. Update any references to use the retained symbol
(depName or reqName) and adjust the module exports accordingly to avoid
duplication while preserving external API.


instance Toml.Schema.FromValue Req where
fromValue v = do
value <- Toml.Schema.fromValue v
Expand Down
46 changes: 45 additions & 1 deletion test/Python/Poetry/CommonSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Strategy.Python.Poetry.PyProject (
PyProjectPoetryPathDependency (..),
PyProjectPoetryUrlDependency (..),
PyProjectTool (..),
allPoetryProductionDeps,
)
import Test.Hspec (
Spec,
Expand Down Expand Up @@ -159,17 +160,60 @@ spec :: Spec
spec = do
nominalContents <- runIO (TIO.readFile "test/Python/Poetry/testdata/pyproject1.toml")
emptyContents <- runIO (TIO.readFile "test/Python/Poetry/testdata/pyproject2.toml")
pep621Contents <- runIO (TIO.readFile "test/Python/Poetry/testdata/pep621/pyproject.toml")
pep621MixedContents <- runIO (TIO.readFile "test/Python/Poetry/testdata/pep621-mixed/pyproject.toml")

describe "toCanonicalName" $ do
it "should convert text to lowercase" $
toCanonicalName "GreatScore" `shouldBe` "greatscore"
it "should replace underscore (_) to hyphens (-)" $
toCanonicalName "my_oh_so_great_pkg" `shouldBe` "my-oh-so-great-pkg"

describe "getDependencies" $
describe "getDependencies" $ do
it "should get all dependencies" $
pyProjectDeps expectedPyProject `shouldMatchList` expectedDeps

it "should get PEP 621 [project].dependencies as production deps" $ do
case decodeEither pep621Contents of
Left e -> fail $ "Failed to parse pyproject.toml: " <> show e
Right pyProject -> do
let deps = pyProjectDeps pyProject
let prodDeps = filter (\d -> Set.singleton EnvProduction == dependencyEnvironments d) deps
let devDeps = filter (\d -> Set.singleton EnvDevelopment == dependencyEnvironments d) deps
map dependencyName prodDeps `shouldMatchList` ["requests", "flask"]
map dependencyName devDeps `shouldMatchList` ["pytest"]

it "should prefer [tool.poetry.dependencies] over [project].dependencies for same package" $ do
case decodeEither pep621MixedContents of
Left e -> fail $ "Failed to parse pyproject.toml: " <> show e
Right pyProject -> do
let deps = pyProjectDeps pyProject
let prodDeps = filter (\d -> Set.singleton EnvProduction == dependencyEnvironments d) deps
-- Both requests and flask should be production deps
map dependencyName prodDeps `shouldMatchList` ["requests", "flask"]

describe "allPoetryProductionDeps" $ do
it "should include PEP 621 [project].dependencies" $ do
case decodeEither pep621Contents of
Left e -> fail $ "Failed to parse pyproject.toml: " <> show e
Right pyProject -> do
let prodDeps = allPoetryProductionDeps pyProject
Map.member "requests" prodDeps `shouldBe` True
Map.member "flask" prodDeps `shouldBe` True

it "should prefer [tool.poetry.dependencies] entries over PEP 621 entries" $ do
case decodeEither pep621MixedContents of
Left e -> fail $ "Failed to parse pyproject.toml: " <> show e
Right pyProject -> do
let prodDeps = allPoetryProductionDeps pyProject
-- requests is in both sections; poetry entry (DetailedVersion) should win
case Map.lookup "requests" prodDeps of
Just (PyProjectPoetryDetailedVersionDependencySpec _) -> pure ()
Just other -> fail $ "Expected DetailedVersionDependencySpec for requests, got: " <> show other
Nothing -> fail "requests not found in production deps"
-- flask is only in [project].dependencies
Map.member "flask" prodDeps `shouldBe` True

describe "supportedPyProjectDep" $
it "should return false when dependency is sourced from local path" $
supportedPyProjectDep notSupportedPyProjectDependency `shouldBe` False
Expand Down
18 changes: 18 additions & 0 deletions test/Python/Poetry/testdata/pep621-mixed/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "my-mixed-project"
version = "1.0.0"
dependencies = [
"requests>=2.28.0",
"flask>=2.0",
]

[tool.poetry.dependencies]
# Poetry-style entry for requests with explicit source — should take precedence
requests = {version = ">=2.28.0"}

[tool.poetry.group.dev.dependencies]
pytest = "^6.0"

[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
14 changes: 14 additions & 0 deletions test/Python/Poetry/testdata/pep621/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "my-pep621-project"
version = "1.0.0"
dependencies = [
"requests>=2.28.0",
"flask>=2.0",
]

[tool.poetry.group.dev.dependencies]
pytest = "^6.0"

[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
Loading