Skip to content

Commit 11d1b85

Browse files
authored
Merge pull request #2638 from nexB/2619-add-cocoapods-parsers
Add podspec.json and podfile.lock parsers
2 parents 884f829 + 179d8c4 commit 11d1b85

File tree

13 files changed

+659
-67
lines changed

13 files changed

+659
-67
lines changed

src/formattedcode/output_csv.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ def get_package_columns(_columns=set()):
243243

244244
# some extra columns for components
245245
extra_columns = [
246+
'purl',
246247
'components',
247248
'owner_name',
248249
'reference_notes',

src/packagedcode/cocoapods.py

Lines changed: 301 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
99

10+
import hashlib
11+
import json
1012
import logging
1113
import re
1214

1315
import attr
16+
import saneyaml
1417

1518
from commoncode import filetype
1619
from packagedcode import models
20+
from packagedcode.licensing import get_license_matches
21+
from packagedcode.licensing import get_license_expression_from_matches
1722
from packagedcode.spec import Spec
1823

24+
from packageurl import PackageURL
25+
1926
"""
2027
Handle cocoapods packages manifests for macOS and iOS
21-
including .podspec, Podfile and Podfile.lock files.
28+
and from Xcode projects, including .podspec, Podfile and Podfile.lock files,
29+
and .podspec.json files from https://github.com/CocoaPods/Specs.
2230
See https://cocoapods.org
2331
"""
2432

@@ -36,23 +44,89 @@
3644

3745
@attr.s()
3846
class CocoapodsPackage(models.Package):
39-
metafiles = ('*.podspec',)
40-
extensions = ('.podspec',)
47+
metafiles = (
48+
'*.podspec',
49+
'*podfile.lock',
50+
'*.podspec.json',
51+
)
52+
extensions = (
53+
'.podspec',
54+
'.lock',
55+
)
4156
default_type = 'pods'
4257
default_primary_language = 'Objective-C'
4358
default_web_baseurl = 'https://cocoapods.org'
44-
default_download_baseurl = None
45-
default_api_baseurl = None
59+
github_specs_repo_baseurl = 'https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs'
60+
default_cdn_baseurl='https://cdn.cocoapods.org/Specs'
4661

4762
@classmethod
4863
def recognize(cls, location):
4964
yield parse(location)
5065

5166
def repository_homepage_url(self, baseurl=default_web_baseurl):
52-
return '{}/pods/{}'.format(baseurl, self.name)
67+
return f'{baseurl}/pods/{self.name}'
5368

5469
def repository_download_url(self):
55-
return '{}/archive/{}.zip'.format(self.homepage_url, self.version)
70+
if self.homepage_url:
71+
return f'{self.homepage_url}/archive/{self.version}.zip'
72+
elif self.reponame:
73+
return f'{self.reponame}/archive/refs/tags/{self.version}.zip'
74+
75+
def get_api_data_url(self):
76+
return self.specs_json_github_url
77+
78+
def get_code_view_url(self):
79+
if isinstance(self.reponame, str):
80+
return self.reponame+'/tree/'+self.version
81+
82+
def get_bug_tracking_url(self):
83+
if isinstance(self.reponame, str):
84+
return self.reponame+'/issues/'
85+
86+
def specs_json_cdn_url(self, baseurl=default_cdn_baseurl):
87+
return f'{baseurl}/{self.hashed_path}/{self.name}/{self.version}/{self.name}.podspec.json'
88+
89+
def specs_json_github_url(self, baseurl=github_specs_repo_baseurl):
90+
return f'{baseurl}/{self.hashed_path}/{self.name}/{self.version}/{self.name}.podspec.json'
91+
92+
@property
93+
def reponame(self):
94+
if isinstance(self.vcs_url, str):
95+
if self.vcs_url[-4:] == '.git':
96+
return self.vcs_url[:-4]
97+
98+
@property
99+
def hashed_path(self):
100+
"""
101+
Returns a string with a part of the file path derived from the md5 hash.
102+
103+
From https://github.com/CocoaPods/cdn.cocoapods.org:
104+
"There are a set of known prefixes for all Podspec paths, you take the name of the pod,
105+
create a SHA (using md5) of it and take the first three characters."
106+
"""
107+
podname = self.get_podname_proper(self.name)
108+
if self.name != podname:
109+
name_to_hash = podname
110+
else:
111+
name_to_hash = self.name
112+
113+
hash_init = self.get_first_3_mdf_hash_characters(name_to_hash)
114+
hashed_path = '/'.join(list(hash_init))
115+
return hashed_path
116+
117+
@staticmethod
118+
def get_first_3_mdf_hash_characters(podname):
119+
return hashlib.md5(podname.encode('utf-8')).hexdigest()[0:3]
120+
121+
@staticmethod
122+
def get_podname_proper(podname):
123+
"""
124+
Podnames in cocoapods sometimes are files inside a pods package (like 'OHHTTPStubs/Default')
125+
This returns proper podname in those cases.
126+
"""
127+
if '/' in podname:
128+
return podname.split('/')[0]
129+
return podname
56130

57131

58132
def is_podspec(location):
@@ -62,21 +136,64 @@ def is_podspec(location):
62136
return (filetype.is_file(location) and location.endswith('.podspec'))
63137

64138

139+
def is_podfile_lock(location):
140+
"""
141+
Checks if the file is actually a podfile.lock file
142+
"""
143+
return (filetype.is_file(location) and location.endswith(('podfile.lock', 'Podfile.lock')))
144+
145+
def is_podspec_json(location):
146+
"""
147+
Checks if the file is actually a podspec.json metadata file
148+
"""
149+
return (filetype.is_file(location) and location.endswith('.podspec.json'))
150+
151+
152+
def read_podspec_json(location):
153+
"""
154+
Reads from podspec.json file at location as JSON.
155+
"""
156+
with open(location, "r") as file:
157+
data = json.load(file)
158+
159+
return data
160+
161+
162+
def read_podfile_lock(location):
163+
"""
164+
Reads from podfile.lock file at location as YML.
165+
"""
166+
with open(location, 'r') as file:
167+
data = saneyaml.load(file)
168+
169+
return data
170+
171+
65172
def parse(location):
66173
"""
67-
Return a Package object from a .podspec file or None.
174+
Return a Package object from:
175+
1. `.podspec` files
176+
2. `.podspec.json` files
177+
3. `podfile.lock` files
178+
or returns None otherwise.
68179
"""
69-
if not is_podspec(location):
70-
return
180+
if is_podspec(location):
181+
podspec_object = Spec()
182+
podspec_data = podspec_object.parse_spec(location)
183+
return build_package(podspec_data)
184+
185+
if is_podspec_json(location):
186+
podspec_json_data = read_podspec_json(location)
187+
return build_xcode_package(podspec_json_data)
71188

72-
podspec_object = Spec()
73-
podspec_data = podspec_object.parse_spec(location)
74-
return build_package(podspec_data)
189+
if is_podfile_lock(location):
190+
podfile_lock_data = read_podfile_lock(location)
191+
return build_xcode_package_from_lockfile(podfile_lock_data)
75192

76193

77194
def build_package(podspec_data):
78195
"""
79-
Return a Package object from a package data mapping or None.
196+
Return a Package object from a podspec.json package data mapping.
80197
"""
81198
name = podspec_data.get('name')
82199
version = podspec_data.get('version')
@@ -170,3 +287,173 @@ def parse_person(person):
170287
email = parsed.group('email')
171288

172289
return name, email
290+
291+
292+
def get_sha1_file(location):
293+
"""
294+
Get sha1 hash for a file at location.
295+
"""
296+
with open(location, "rb") as f:
297+
return hashlib.sha1(f.read()).hexdigest()
298+
299+
300+
def build_xcode_package(podspec_json_data):
301+
"""
302+
Return a Package object from a podspec.json package data mapping.
303+
"""
304+
name = podspec_json_data.get('name')
305+
version = podspec_json_data.get('version')
306+
summary = podspec_json_data.get('summary', '')
307+
description = podspec_json_data.get('description', '')
308+
homepage_url = podspec_json_data.get('homepage')
309+
310+
license = podspec_json_data.get('license')
311+
if isinstance(license, dict):
312+
declared_license = ' '.join(list(license.values()))
313+
else:
314+
declared_license = license
315+
316+
source = podspec_json_data.get('source')
317+
vcs_url = None
318+
download_url = None
319+
320+
if isinstance(source, dict):
321+
git_url = source.get('git', '')
322+
http_url = source.get('http', '')
323+
if git_url:
324+
vcs_url = git_url
325+
elif http_url:
326+
download_url = http_url
327+
328+
if not vcs_url:
329+
vcs_url = source
330+
331+
authors = podspec_json_data.get('authors') or {}
332+
333+
license_matches = get_license_matches(query_string=declared_license)
334+
if not license_matches:
335+
license_expression = 'unknown'
336+
else:
337+
license_expression = get_license_expression_from_matches(license_matches)
338+
339+
if summary and not description.startswith(summary):
340+
desc = [summary]
341+
if description:
342+
desc += [description]
343+
description = '. '.join(desc)
344+
345+
parties = []
346+
if authors:
347+
if isinstance(authors, dict):
348+
for key, value in authors.items():
349+
party = models.Party(
350+
type=models.party_org,
351+
name=key,
352+
url=value+'.com',
353+
role='owner'
354+
)
355+
parties.append(party)
356+
else:
357+
party = models.Party(
358+
type=models.party_org,
359+
name=authors,
360+
role='owner'
361+
)
362+
parties.append(party)
363+
364+
extra_data = {}
365+
extra_data['source'] = podspec_json_data['source']
366+
dependencies = podspec_json_data.get('dependencies', '')
367+
if dependencies:
368+
extra_data['dependencies'] = dependencies
369+
extra_data['podspec.json'] = podspec_json_data
370+
371+
package = CocoapodsPackage(
372+
name=name,
373+
version=version,
374+
vcs_url=vcs_url,
375+
description=description,
376+
declared_license=declared_license,
377+
license_expression=license_expression,
378+
homepage_url=homepage_url,
379+
download_url=download_url,
380+
parties=parties,
381+
)
382+
383+
package.api_data_url = package.get_api_data_url()
384+
385+
return package
386+
387+
388+
def get_data_from_pods(dep_pod_version):
389+
390+
if '(' in dep_pod_version:
391+
podname, _, version = dep_pod_version.strip(')').partition(' (')
392+
else:
393+
version = None
394+
podname = dep_pod_version
395+
396+
if '/' in podname:
397+
namespace, _, podname = podname.partition('/')
398+
else:
399+
namespace = None
400+
401+
return podname, namespace, version
402+
403+
404+
def build_xcode_package_from_lockfile(podfile_lock_data):
405+
"""
406+
Return a Package object from a data mapping obtained from a podfile.lock
407+
"""
408+
pods = podfile_lock_data['PODS']
409+
pod_deps = []
410+
411+
for pod in pods:
412+
413+
if isinstance(pod, dict):
414+
for main_pod, _dep_pods in pod.items():
415+
416+
podname, namespace, version = get_data_from_pods(main_pod)
417+
418+
purl = PackageURL(
419+
type='pods',
420+
namespace=namespace,
421+
name=podname,
422+
version=version,
423+
).to_string()
424+
425+
pod_deps.append(
426+
models.DependentPackage(
427+
purl=purl,
428+
scope='requires-dev',
429+
requirement=version,
430+
is_runtime=False,
431+
is_optional=True,
432+
is_resolved=True,
433+
)
434+
)
435+
436+
elif isinstance(pod, str):
437+
podname, namespace, version = get_data_from_pods(pod)
438+
purl = PackageURL(
439+
type='pods',
440+
namespace=namespace,
441+
name=podname,
442+
version=version,
443+
).to_string()
444+
445+
pod_deps.append(
446+
models.DependentPackage(
447+
purl=purl,
448+
scope='requires-dev',
449+
requirement=version,
450+
is_runtime=False,
451+
is_optional=True,
452+
is_resolved=True,
453+
)
454+
)
455+
456+
yield CocoapodsPackage(
457+
dependencies=pod_deps,
458+
declared_license=None,
459+
)

0 commit comments

Comments
 (0)