Skip to content

Commit 759be18

Browse files
authored
Poetry: Support v1.5.0 or greater (#1420)
1 parent 2458583 commit 759be18

File tree

10 files changed

+1067
-79
lines changed

10 files changed

+1067
-79
lines changed

Changelog.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# FOSSA CLI Changelog
22

3+
## v3.9.17
4+
- Poetry: Adds partial support for dependency groups. ([#1420](https://github.com/fossas/fossa-cli/pull/1420)).
5+
36
## v3.9.16
47
- Treat `targets` field in the issue summary loaded from Core as optional during `fossa test` and `fossa report` ([#1422](https://github.com/fossas/fossa-cli/pull/1422)).
58
- Adds support for SwiftPM v3 files ([#1424](https://github.com/fossas/fossa-cli/pull/1424)).
@@ -13,7 +16,7 @@
1316
SAAS customers are unaffected. ([#1418](https://github.com/fossas/fossa-cli/pull/1418)).
1417

1518
## v3.9.14
16-
- Update cargo strategy to parse new `cargo metadata` format for cargo >= 1.77.0 ([#1416](https://github.com/fossas/fossa-cli/pull/1416)).
19+
- Cargo: Update cargo strategy to parse new `cargo metadata` format for cargo >= 1.77.0 ([#1416](https://github.com/fossas/fossa-cli/pull/1416)).
1720
- `fossa release-group`: Add command to create a FOSSA release group release (`fossa release-group create-release`) [#1409](https://github.com/fossas/fossa-cli/pull/1409).
1821
- `fossa project`: Adds commands to interact with FOSSA projects (`fossa project edit`) [#1394](https://github.com/fossas/fossa-cli/pull/1395).
1922

docs/references/strategies/languages/python/poetry.md

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ If `poetry.lock` file is not discovered, we fallback to reporting only direct de
3636
- For poetry project, build system's `build-backend` must be set to `poetry.core.masonry.api` or `poetry.masonry.api` in `pyproject.toml`. If not done so, it will not discover the project. Refer to [Poetry and PEP-517](https://python-poetry.org/docs/pyproject/#poetry-and-pep-517) for more details.
3737
- All extras specified in `[tool.poetry.extras]` are currently not reported.
3838
- Any [path dependencies](https://python-poetry.org/docs/dependency-specification/#path-dependencies) will not be reported.
39+
- For Poetry version greater or equal to `v1.5.0`, optional dependencies provideded in [dependencies group](https://python-poetry.org/docs/managing-dependencies/#dependency-groups) will not be included in the analysis, even with [--include-unused-deps](../../../subcommands/analyze.md), if only `pyproject.toml` is discovered.
3940

4041
## Example
4142

@@ -181,6 +182,9 @@ _Dependencies highlighted in yellow boxes are direct dependencies, rest are tran
181182

182183
Without `poetry.lock` we are not able to identify any transitive dependencies. We are also unable to locally resolve dependency when version ranges are provided, like `loguru = "^0.5"`.
183184

185+
As `category` is not provided with poetry version greater or equal to [v1.5.0](https://github.com/dependabot/dependabot-core/pull/7418), FOSSA CLI will, first identify "main" dependencies by
186+
using `tool.poetry.dependencies` from `pyproject.toml`. Afterwhich, it will [hydrate](../../../../contributing/graph-hydration.md) dependencies. Any dependencies not hydrated, will be inferred to be a development dependency.
187+
184188
### References
185189

186190
- [Poetry Source Code](https://github.com/python-poetry/poetry)

src/Strategy/Python/Poetry.hs

+55-22
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ module Strategy.Python.Poetry (
22
discover,
33

44
-- * for testing only
5-
graphFromLockFile,
6-
setGraphDirectsFromPyproject,
5+
findProjects,
6+
analyze,
7+
graphFromPyProjectAndLockFile,
78
PoetryProject (..),
9+
PyProjectTomlFile (..),
10+
PoetryLockFile (..),
11+
ProjectDir (..),
812
) where
913

1014
import App.Fossa.Analyze.Types (AnalyzeProject (analyzeProjectStaticOnly), analyzeProject)
@@ -17,8 +21,10 @@ import Data.Aeson (ToJSON)
1721
import Data.Map (Map)
1822
import Data.Map.Strict qualified as Map
1923
import Data.Maybe (fromMaybe)
24+
import Data.Set qualified as Set
2025
import Data.Text (Text)
21-
import DepTypes (DepType (..), Dependency (..))
26+
import Data.Text qualified as Text
27+
import DepTypes (DepEnvironment (EnvDevelopment), DepType (..), Dependency (..), hydrateDepEnvs)
2228
import Diag.Common (
2329
MissingDeepDeps (MissingDeepDeps),
2430
MissingEdges (MissingEdges),
@@ -40,9 +46,9 @@ import Strategy.Python.Errors (
4046
MissingPoetryLockFile (..),
4147
commitPoetryLockToVCS,
4248
)
43-
import Strategy.Python.Poetry.Common (getPoetryBuildBackend, logIgnoredDeps, pyProjectDeps, toCanonicalName, toMap)
44-
import Strategy.Python.Poetry.PoetryLock (PackageName (..), PoetryLock (..), PoetryLockPackage (..), poetryLockCodec)
45-
import Strategy.Python.Poetry.PyProject (PyProject (..), pyProjectCodec)
49+
import Strategy.Python.Poetry.Common (getPoetryBuildBackend, logIgnoredDeps, makePackageToLockDependencyMap, pyProjectDeps, toCanonicalName)
50+
import Strategy.Python.Poetry.PoetryLock (PackageName (..), PoetryLock (..), PoetryLockPackage (..), PoetryMetadata (poetryMetadataLockVersion), poetryLockCodec)
51+
import Strategy.Python.Poetry.PyProject (PyProject (..), allPoetryProductionDeps, pyProjectCodec)
4652
import Types (DependencyResults (..), DiscoveredProject (..), DiscoveredProjectType (PoetryProjectType), GraphBreadth (..))
4753

4854
newtype PyProjectTomlFile = PyProjectTomlFile {pyProjectTomlPath :: Path Abs File} deriving (Eq, Ord, Show, Generic)
@@ -154,7 +160,7 @@ analyze PoetryProject{pyProjectToml, poetryLock} = do
154160
Just lockPath -> do
155161
poetryLockProject <- readContentsToml poetryLockCodec (poetryLockPath lockPath)
156162
_ <- logIgnoredDeps pyproject (Just poetryLockProject)
157-
graph <- context "Building dependency graph from pyproject.toml and poetry.lock" $ pure $ setGraphDirectsFromPyproject (graphFromLockFile poetryLockProject) pyproject
163+
graph <- context "Building dependency graph from pyproject.toml and poetry.lock" . pure $ graphFromPyProjectAndLockFile pyproject poetryLockProject
158164
pure $
159165
DependencyResults
160166
{ dependencyGraph = graph
@@ -170,31 +176,55 @@ analyze PoetryProject{pyProjectToml, poetryLock} = do
170176
. errHelp MissingPoetryLockFileHelp
171177
. errDoc commitPoetryLockToVCS
172178
$ fatalText "poetry.lock file was not discovered"
173-
graph <- context "Building dependency graph from only pyproject.toml" $ pure $ Graphing.fromList $ pyProjectDeps pyproject
179+
graph <- context "Building dependency graph from only pyproject.toml" . pure $ Graphing.fromList $ pyProjectDeps pyproject
174180
pure $
175181
DependencyResults
176182
{ dependencyGraph = graph
177183
, dependencyGraphBreadth = Partial
178184
, dependencyManifestFiles = [pyProjectTomlPath pyProjectToml]
179185
}
180186

181-
-- | Use a `pyproject.toml` to set the direct dependencies of a graph created from `poetry.lock`.
182-
setGraphDirectsFromPyproject :: Graphing Dependency -> PyProject -> Graphing Dependency
183-
setGraphDirectsFromPyproject graph pyproject = Graphing.promoteToDirect isDirect graph
184-
where
185-
-- Dependencies in `poetry.lock` are direct if they're specified in `pyproject.toml`.
186-
-- `pyproject.toml` may use non canonical naming, when naming dependencies.
187-
isDirect :: Dependency -> Bool
188-
isDirect dep = case pyprojectPoetry pyproject of
189-
Nothing -> False
190-
Just _ -> any (\n -> toCanonicalName (dependencyName n) == toCanonicalName (dependencyName dep)) $ pyProjectDeps pyproject
191-
192187
-- | Using a Poetry lockfile, build the graph of packages.
193188
-- The resulting graph contains edges, but does not distinguish between direct and deep dependencies,
194189
-- since `poetry.lock` does not indicate which dependencies are direct.
195-
graphFromLockFile :: PoetryLock -> Graphing Dependency
196-
graphFromLockFile poetryLock = Graphing.gmap pkgNameToDependency (edges <> Graphing.deeps pkgsNoDeps)
190+
graphFromPyProjectAndLockFile :: PyProject -> PoetryLock -> Graphing Dependency
191+
graphFromPyProjectAndLockFile pyProject poetryLock = graph
197192
where
193+
-- Since Poetry lockfile v1.5 and gt, does not include category marker
194+
-- it is not possible to know if the dependency is 'production' or 'development'.
195+
-- strictly from lockfile itself. We hydrate "environment" from direct deps. If the
196+
-- dependency has no environment, we mark it as dev environment. We know that all production
197+
-- dependencies will be hydrated and will have some envionment.
198+
graph :: Graphing Dependency
199+
graph =
200+
labelOptionalDepsIfPoetryGt1_5 $
201+
hydrateDepEnvs $
202+
Graphing.promoteToDirect isDirect $
203+
Graphing.gmap pkgNameToDependency (edges <> Graphing.deeps pkgsNoDeps)
204+
205+
labelOptionalDepsIfPoetryGt1_5 :: Graphing Dependency -> Graphing Dependency
206+
labelOptionalDepsIfPoetryGt1_5 g = if isLockLt1_5 then g else Graphing.gmap markEmptyEnvAsOptionalDep g
207+
208+
isLockLt1_5 :: Bool
209+
isLockLt1_5 = any (`Text.isPrefixOf` lockVersion) ["0", "1.0", "1.1", "1.2", "1.3", "1.4"]
210+
211+
lockVersion :: Text
212+
lockVersion = poetryMetadataLockVersion . poetryLockMetadata $ poetryLock
213+
214+
markEmptyEnvAsOptionalDep :: Dependency -> Dependency
215+
markEmptyEnvAsOptionalDep d =
216+
if null $ dependencyEnvironments d
217+
then d{dependencyEnvironments = Set.singleton EnvDevelopment}
218+
else d
219+
220+
directDeps :: [Dependency]
221+
directDeps = pyProjectDeps pyProject
222+
223+
isDirect :: Dependency -> Bool
224+
isDirect dep = case pyprojectPoetry pyProject of
225+
Nothing -> False
226+
Just _ -> any (\n -> toCanonicalName (dependencyName n) == toCanonicalName (dependencyName dep)) directDeps
227+
198228
pkgs :: [PoetryLockPackage]
199229
pkgs = poetryLockPackages poetryLock
200230

@@ -217,7 +247,10 @@ graphFromLockFile poetryLock = Graphing.gmap pkgNameToDependency (edges <> Graph
217247
canonicalPkgName name = PackageName . toCanonicalName $ unPackageName name
218248

219249
mapOfDependency :: Map PackageName Dependency
220-
mapOfDependency = toMap pkgs
250+
mapOfDependency = makePackageToLockDependencyMap prodPkgNames pkgs
251+
252+
prodPkgNames :: [PackageName]
253+
prodPkgNames = PackageName <$> Map.keys (allPoetryProductionDeps pyProject)
221254

222255
-- Pip packages are [case insensitive](https://www.python.org/dev/peps/pep-0508/#id21), but poetry.lock may use
223256
-- non-canonical name for reference. Try to lookup with provided name, otherwise fallback to canonical naming.

src/Strategy/Python/Poetry/Common.hs

+54-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module Strategy.Python.Poetry.Common (
22
getPoetryBuildBackend,
3-
toMap,
3+
makePackageToLockDependencyMap,
44
pyProjectDeps,
55
logIgnoredDeps,
66
toCanonicalName,
@@ -35,6 +35,7 @@ import Strategy.Python.Poetry.PyProject (
3535
PyProjectPoetryGitDependency (..),
3636
PyProjectPoetryPathDependency (..),
3737
PyProjectPoetryUrlDependency (..),
38+
allPoetryNonProductionDeps,
3839
toDependencyVersion,
3940
)
4041

@@ -63,7 +64,7 @@ logIgnoredDeps pyproject poetryLock = for_ notSupportedDepsMsgs (logDebug . pret
6364
notSupportedPyProjectDevDeps =
6465
Map.keys $
6566
Map.filter (not . supportedPyProjectDep) $
66-
maybe Map.empty devDependencies (pyprojectPoetry pyproject)
67+
allPoetryNonProductionDeps pyproject
6768

6869
notSupportedPyProjectDeps :: [Text]
6970
notSupportedPyProjectDeps =
@@ -83,7 +84,28 @@ pyProjectDeps project = filter notNamedPython $ map snd allDeps
8384
notNamedPython = (/= "python") . dependencyName
8485

8586
supportedDevDeps :: Map Text PoetryDependency
86-
supportedDevDeps = Map.filter supportedPyProjectDep $ maybe Map.empty devDependencies (pyprojectPoetry project)
87+
supportedDevDeps = Map.filter supportedPyProjectDep $ Map.unions [olderPoetryDevDeps, groupDeps]
88+
89+
-- These are dependencies coming from dev-dependencies table
90+
-- which is pre 1.2.x style, understood by Poetry 1.0–1.2
91+
olderPoetryDevDeps :: Map Text PoetryDependency
92+
olderPoetryDevDeps = case pyprojectPoetry project of
93+
Just (PyProjectPoetry{devDependencies}) -> devDependencies
94+
_ -> mempty
95+
96+
-- These are 'group' dependencies. All group dependencies are optional.
97+
-- Due to current toml parsing library limitation (specifically implicit table parsing support)
98+
-- We only support dev, and test group. We may miss other development dependencies in our findings
99+
-- if they are not named under 'dev' or 'test' group. This is not ideal, but is good partial solution
100+
-- as optional deps are not included in the final analysis by default.
101+
--
102+
-- Refs:
103+
-- \* https://github.com/kowainik/tomland/issues/336
104+
-- \* https://python-poetry.org/docs/managing-dependencies#dependency-groups
105+
groupDeps :: Map Text PoetryDependency
106+
groupDeps = case pyprojectPoetry project of
107+
Just (PyProjectPoetry{groupDevDependencies, groupTestDependencies}) -> Map.unions [groupDevDependencies, groupTestDependencies]
108+
_ -> mempty
87109

88110
supportedProdDeps :: Map Text PoetryDependency
89111
supportedProdDeps = Map.filter supportedPyProjectDep $ maybe Map.empty dependencies (pyprojectPoetry project)
@@ -154,11 +176,20 @@ toCanonicalName :: Text -> Text
154176
toCanonicalName t = toLower $ replace "_" "-" (replace "." "-" t)
155177

156178
-- | Maps poetry lock package to map of package name and associated dependency.
157-
toMap :: [PoetryLockPackage] -> Map.Map PackageName Dependency
158-
toMap pkgs = Map.fromList $ (\x -> (canonicalPkgName x, toDependency x)) <$> (filter supportedPoetryLockDep pkgs)
179+
makePackageToLockDependencyMap :: [PackageName] -> [PoetryLockPackage] -> Map.Map PackageName Dependency
180+
makePackageToLockDependencyMap prodPkgs pkgs = Map.fromList $ (\x -> (lockCanonicalPackageName x, toDependency x)) <$> (filter supportedPoetryLockDep pkgs)
159181
where
160-
canonicalPkgName :: PoetryLockPackage -> PackageName
161-
canonicalPkgName pkg = PackageName $ toCanonicalName $ unPackageName $ poetryLockPackageName pkg
182+
canonicalPkgName :: PackageName -> PackageName
183+
canonicalPkgName = PackageName . toCanonicalName . unPackageName
184+
185+
lockCanonicalPackageName :: PoetryLockPackage -> PackageName
186+
lockCanonicalPackageName = canonicalPkgName . poetryLockPackageName
187+
188+
canonicalProdPkgNames :: Set.Set PackageName
189+
canonicalProdPkgNames = Set.fromList $ map canonicalPkgName prodPkgs
190+
191+
isProductionDirectDep :: PoetryLockPackage -> Bool
192+
isProductionDirectDep pkg = lockCanonicalPackageName pkg `Set.member` canonicalProdPkgNames
162193

163194
toDependency :: PoetryLockPackage -> Dependency
164195
toDependency pkg =
@@ -167,7 +198,7 @@ toMap pkgs = Map.fromList $ (\x -> (canonicalPkgName x, toDependency x)) <$> (fi
167198
, dependencyName = toDepName pkg
168199
, dependencyVersion = toDepVersion pkg
169200
, dependencyLocations = toDepLocs pkg
170-
, dependencyEnvironments = Set.singleton $ toDepEnvironment pkg
201+
, dependencyEnvironments = pkgEnvironments pkg
171202
, dependencyTags = Map.empty
172203
}
173204

@@ -200,16 +231,19 @@ toMap pkgs = Map.fromList $ (\x -> (canonicalPkgName x, toDependency x)) <$> (fi
200231
ref <- poetryLockPackageSourceReference lockPkgSrc
201232
if poetryLockPackageSourceType lockPkgSrc /= "legacy" then Just ref else Nothing
202233

203-
toDepEnvironment :: PoetryLockPackage -> DepEnvironment
204-
toDepEnvironment pkg = case poetryLockPackageCategory pkg of
234+
pkgEnvironments :: PoetryLockPackage -> Set.Set DepEnvironment
235+
pkgEnvironments pkg = case poetryLockPackageCategory pkg of
236+
-- If category is provided, use category to infer if dependency's environment
205237
Just category -> case category of
206-
"dev" -> EnvDevelopment
207-
"main" -> EnvProduction
208-
"test" -> EnvTesting
209-
other -> EnvOther other
210-
Nothing -> defaultDepEnvironment
211-
212-
defaultDepEnvironment :: DepEnvironment
213-
-- Poetry made this field optional. When not present, it defaults to `main`, which maps to `EnvProduction`.
214-
-- https://github.com/python-poetry/poetry/pull/7637
215-
defaultDepEnvironment = EnvProduction
238+
"dev" -> Set.singleton EnvDevelopment
239+
"main" -> Set.singleton EnvProduction
240+
"test" -> Set.singleton EnvTesting
241+
other -> Set.singleton $ EnvOther other
242+
-- If category is not provided, lockfile is likely greater than __.
243+
-- In this case, if the package name exists in the dependencies
244+
-- list, mark as production dependency, otherwise, mark it as development dependency
245+
-- -
246+
-- Refer to:
247+
-- \* https://github.com/python-poetry/poetry/pull/7637
248+
Nothing ->
249+
(if isProductionDirectDep pkg then Set.singleton EnvProduction else mempty)

src/Strategy/Python/Poetry/PyProject.hs

+34-2
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ module Strategy.Python.Poetry.PyProject (
99
PyProjectPoetryGitDependency (..),
1010
PyProjectPoetryUrlDependency (..),
1111
PyProjectPoetryDetailedVersionDependency (..),
12+
allPoetryProductionDeps,
1213

1314
-- * for testing only
1415
parseConstraintExpr,
1516
toDependencyVersion,
17+
allPoetryNonProductionDeps,
1618
) where
1719

1820
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
1921
import Data.Foldable (asum)
2022
import Data.Functor (void)
21-
import Data.Map (Map)
23+
import Data.Map (Map, unions)
2224
import Data.Maybe (fromMaybe)
2325
import Data.String.Conversion (toString, toText)
2426
import Data.Text (Text)
@@ -113,10 +115,38 @@ data PyProjectPoetry = PyProjectPoetry
113115
, version :: Maybe Text
114116
, description :: Maybe Text
115117
, dependencies :: Map Text PoetryDependency
116-
, devDependencies :: Map Text PoetryDependency
118+
, -- For Poetry pre-1.2.x style, understood by Poetry 1.0–1.2
119+
devDependencies :: Map Text PoetryDependency
120+
, -- Since v1.2.0 of poetry, dependency groups are recommanded way to
121+
-- provide development, test, and other optional dependencies.
122+
-- refer to: https://python-poetry.org/docs/managing-dependencies#dependency-groups
123+
--
124+
-- Due to current toml-parsing limitations, we explicitly specify dev, and
125+
-- test group only. Note that any dependencies from these groups are excluded
126+
-- by default. refer to: https://github.com/kowainik/tomland/issues/336
127+
groupDevDependencies :: Map Text PoetryDependency
128+
, groupTestDependencies :: Map Text PoetryDependency
117129
}
118130
deriving (Show, Eq, Ord)
119131

132+
allPoetryProductionDeps :: PyProject -> Map Text PoetryDependency
133+
allPoetryProductionDeps project = case pyprojectPoetry project of
134+
Just (PyProjectPoetry{dependencies}) -> dependencies
135+
_ -> mempty
136+
137+
allPoetryNonProductionDeps :: PyProject -> Map Text PoetryDependency
138+
allPoetryNonProductionDeps project = unions [olderPoetryDevDeps, optionalDeps]
139+
where
140+
optionalDeps :: Map Text PoetryDependency
141+
optionalDeps = case pyprojectPoetry project of
142+
Just (PyProjectPoetry{groupDevDependencies, groupTestDependencies}) -> unions [groupDevDependencies, groupTestDependencies]
143+
_ -> mempty
144+
145+
olderPoetryDevDeps :: Map Text PoetryDependency
146+
olderPoetryDevDeps = case pyprojectPoetry project of
147+
Just (PyProjectPoetry{devDependencies}) -> devDependencies
148+
_ -> mempty
149+
120150
data PoetryDependency
121151
= PoetryTextVersion Text
122152
| PyProjectPoetryDetailedVersionDependencySpec PyProjectPoetryDetailedVersionDependency
@@ -133,6 +163,8 @@ pyProjectPoetryCodec =
133163
<*> Toml.dioptional (Toml.text "description") .= description
134164
<*> Toml.tableMap Toml._KeyText pyProjectPoetryDependencyCodec "dependencies" .= dependencies
135165
<*> Toml.tableMap Toml._KeyText pyProjectPoetryDependencyCodec "dev-dependencies" .= devDependencies
166+
<*> Toml.tableMap Toml._KeyText pyProjectPoetryDependencyCodec "group.dev.dependencies" .= groupDevDependencies
167+
<*> Toml.tableMap Toml._KeyText pyProjectPoetryDependencyCodec "group.test.dependencies" .= groupTestDependencies
136168

137169
pyProjectPoetryDependencyCodec :: Toml.Key -> TomlCodec PoetryDependency
138170
pyProjectPoetryDependencyCodec key =

0 commit comments

Comments
 (0)