Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cocoapods support to package.py #119

Merged
merged 13 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ text-unidecode==1.3
toml==0.10.2
typecode==30.0.0
typecode-libmagic==5.39.210531
univers==30.11.0
urllib3==1.26.9
urlpy==0.5
wcwidth==0.2.5
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ install_requires =
requests
python-dateutil
python-dotenv
univers >= 30.11.0


[options.packages.find]
Expand All @@ -80,4 +81,3 @@ docs =
sphinx-rtd-theme>=1.0.0
sphinx-reredirects >= 0.1.2
doc8>=0.11.2

47 changes: 47 additions & 0 deletions src/fetchcode/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
from fetchcode.package_util import GitHubSource
from fetchcode.package_util import MiniupnpPackagesGitHubSource
from fetchcode.package_util import OpenSSLGitHubSource
from fetchcode.package_util import construct_cocoapods_package
from fetchcode.package_util import get_cocoapod_tags
from fetchcode.packagedcode_models import Package
from fetchcode.utils import get_hashed_path
from fetchcode.utils import get_response

router = Router()
Expand Down Expand Up @@ -362,6 +365,50 @@ def get_gnu_data_from_purl(purl):
)


@router.route("pkg:cocoapods/.*")
def get_cocoapods_data_from_purl(purl):
Copy link
Member

Choose a reason for hiding this comment

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

@johnmhoran after refactoring get_cocoapods_data_from_purl into multiple functions, please put those functions in package_util.py and only keep the top-level get_cocoapods_data_from_purl function in package.py file

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks @keshav-space -- I was wondering about that, given how the other existing, relatively short @router.route() functions in package.py have related functions in both package_util.py and utils.py. I've already added a handful of utilities to utils.py for cocoapods support (siblings of existing utilities, but these do not throw exceptions because that stops the purlcli metadata command, which we don't want to do) and will do as you suggest with the now 4 additional functions for cocoapods created by my almost-finished refactoring. And then I have 3 or 4 mock tests to create.

Copy link
Member Author

Choose a reason for hiding this comment

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

@keshav-space Moving these related functions to package_util.py raises one question: in order to facilitate the collection and sharing of cocoapods data from a number of different sources, I've created a dictionary at the top of package.py which all functions can access. When I move some functions to package_util.py, will continued access be as simple as importing that dictionary from package.py into package_util.py? That's my plan atm.

Copy link
Member Author

Choose a reason for hiding this comment

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

@keshav-space I am having trouble importing and accessing in package_util.py the logger I've defined and use widely in my package.py code. I'll dig into this soon, but meanwhile, do you have any guidance on how to share a logging function -- this prints to screen and to the "errors"/"warnings" keys in the JSON output. I now import in package_util.py with from fetchcode.package import logger but get this error running metadata:

(venv) Wed May 01, 2024 08:33 AM  /home/jmh/dev/nexb/purldb jmh (365-update-cocoapods-pypi-support)
$ python -m purldb_toolkit.purlcli metadata --purl pkg:cocoapods/[email protected] --output -
Traceback (most recent call last):
  File "/usr/lib/python3.10/runpy.py", line 196, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.10/runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File "/home/jmh/dev/nexb/purldb/purldb-toolkit/src/purldb_toolkit/purlcli.py", line 19, in <module>
    from fetchcode.package import info
  File "/home/jmh/dev/nexb/fetchcode/src/fetchcode/package.py", line 32, in <module>
    from fetchcode.package_util import GITHUB_SOURCE_BY_PACKAGE
  File "/home/jmh/dev/nexb/fetchcode/src/fetchcode/package_util.py", line 25, in <module>
    from fetchcode.package import logger
ImportError: cannot import name 'logger' from partially initialized module 'fetchcode.package' (most likely due to a circular import) (/home/jmh/dev/nexb/fetchcode/src/fetchcode/package.py)

(venv) Wed May 01, 2024 08:48 AM  /home/jmh/dev/nexb/purldb jmh (365-update-cocoapods-pypi-support)
$

Copy link
Member

Choose a reason for hiding this comment

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

@johnmhoran please don't share the same logger across different files. Define a new logger for package_util.py and avoid any circular dependencies i.e. don't import anything from package.py in package_util.py. The error above is due to a circular dependency.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks @keshav-space . I've defined the logger in each of package.py and package_util.py (configured in get_cocoapods_data_from_purl()), and have defined the pod_summary dictionary in package_util.py and import it into package.py (pod_summary is shared among functions in both files), and everything seems to still work as desired. 👍

purl = PackageURL.from_string(purl)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Put this in try/except block, given input may not be a valid PURL

Copy link
Member Author

@johnmhoran johnmhoran Apr 29, 2024

Choose a reason for hiding this comment

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

Thank you @TG1999 . I'm in the midst of refactoring but will add this to the updated code. One note: there are nearly a dozen other uses of that same syntax by other supported PURL types in package.py and none uses a try/except (but perhaps should?).

Copy link
Member Author

Choose a reason for hiding this comment

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

@TG1999 On second thought, purldb-toolkit's purlcli.py already handles invalid PURL inputs by checking the validate endpoint (including for the metadata command, which is the command that calls the fetchcode package.py info() function) and prints a warning to the output JSON warnings list, so I don't think a try/except is needed in the package.py cocoapods code. E.g.,

(venv) Mon Apr 29, 2024 12:25 PM  /home/jmh/dev/nexb/purldb jmh (365-update-cocoapods-pypi-support)
$ python -m purldb_toolkit.purlcli metadata --purl pkg:cocoapods/# --output -
{
    "headers": [
        {
            "tool_name": "purlcli",
            "tool_version": "0.2.0",
            "options": {
                "command": "metadata",
                "--purl": [
                    "pkg:cocoapods/#"
                ],
                "--file": null,
                "--output": "<stdout>"
            },
            "purls": [
                "pkg:cocoapods/#"
            ],
            "errors": [],
            "warnings": [
                "'pkg:cocoapods/#' not valid"
            ]
        }
    ],
    "packages": []
}
(venv) Mon Apr 29, 2024 12:29 PM  /home/jmh/dev/nexb/purldb jmh (365-update-cocoapods-pypi-support)
$

name = purl.name
cocoapods_org_url = f"https://cocoapods.org/pods/{name}"
api = "https://cdn.cocoapods.org"
hashed_path = get_hashed_path(name)
hashed_path_underscore = hashed_path.replace("/", "_")
file_prefix = "all_pods_versions_"
spec = f"{api}/{file_prefix}{hashed_path_underscore}.txt"
data_list = get_cocoapod_tags(spec, name)

for tag in data_list:
if purl.version and tag != purl.version:
continue

gh_repo_owner = None
gh_repo_name = name
podspec_api_url = f"https://raw.githubusercontent.com/CocoaPods/Specs/master/Specs/{hashed_path}/{name}/{tag}/{name}.podspec.json"
podspec_api_response = get_response(podspec_api_url)
podspec_homepage = podspec_api_response.get('homepage')

if podspec_homepage.startswith("https://github.com/"):
podspec_homepage_remove_gh_prefix = podspec_homepage.replace("https://github.com/", "")
podspec_homepage_split = podspec_homepage_remove_gh_prefix.split("/")
gh_repo_owner = podspec_homepage_split[0]
gh_repo_name = podspec_homepage_split[-1]

tag_pkg = construct_cocoapods_package(
purl,
name,
hashed_path,
cocoapods_org_url,
gh_repo_owner,
gh_repo_name,
tag
)

yield tag_pkg

if purl.version:
break


@dataclasses.dataclass
class DirectoryListedSource:
source_url: str = dataclasses.field(
Expand Down
104 changes: 104 additions & 0 deletions src/fetchcode/package_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,3 +723,107 @@ def get_package_info(cls, gh_purl, package_name):
"date": "2002-08-19T04:23:00",
},
}


def get_cocoapod_tags(spec, name):
try:
response = utils.get_text_response(spec)
data = response.strip()
for line in data.splitlines():
line = line.strip()
if line.startswith(name):
data_list = line.split("/")
if data_list[0] == name:
data_list.pop(0)
return data_list
return None
except:
return None


def construct_cocoapods_package(
purl,
name,
hashed_path,
cocoapods_org_url,
gh_repo_owner,
gh_repo_name,
tag
):
name = name
homepage_url = None
vcs_url = None
github_url = None
bug_tracking_url = None
code_view_url = None
license_data = None
declared_license = None
primary_language = None

if gh_repo_owner and gh_repo_name:
base_path = "https://api.github.com/repos"
api_url = f"{base_path}/{gh_repo_owner}/{gh_repo_name}"
gh_repo_api_response = utils.get_github_rest(api_url)
gh_repo_api_head_request = utils.make_head_request(api_url)
gh_repo_api_status_code = gh_repo_api_head_request.status_code

if gh_repo_api_status_code == 200:
homepage_url = gh_repo_api_response.get("homepage")
vcs_url = gh_repo_api_response.get("git_url")
license_data = gh_repo_api_response.get("license") or {}
declared_license = license_data.get("spdx_id")
primary_language = gh_repo_api_response.get("language")

github_url = "https://github.com"
bug_tracking_url = f"{github_url}/{gh_repo_owner}/{gh_repo_name}/issues"
code_view_url = f"{github_url}/{gh_repo_owner}/{gh_repo_name}"

podspec_api_url = f"https://raw.githubusercontent.com/CocoaPods/Specs/master/Specs/{hashed_path}/{name}/{tag}/{name}.podspec.json"
podspec_api_response = utils.get_response(podspec_api_url)
homepage_url = podspec_api_response.get("homepage")

lic = podspec_api_response.get("license")
extracted_license_statement = None
if isinstance(lic, dict):
extracted_license_statement = lic
else:
extracted_license_statement = lic
if not declared_license:
declared_license = extracted_license_statement

source = podspec_api_response.get("source")
download_url = None
if isinstance(source, dict):
git_url = source.get("git", "")
http_url = source.get("http", "")
if http_url:
download_url = http_url
if git_url and not http_url:
if git_url.endswith(".git") and git_url.startswith("https://github.com/"):
gh_path = git_url[:-4]
github_tag = source.get("tag")
if github_tag and github_tag.startswith("v"):
tag = github_tag
download_url = f"{gh_path}/archive/refs/tags/{tag}.tar.gz"
vcs_url = git_url
elif git_url:
vcs_url = git_url
elif isinstance(source, str):
if not vcs_url:
vcs_url = source

purl_pkg = Package(
homepage_url=homepage_url,
api_url=podspec_api_url,
bug_tracking_url=bug_tracking_url,
code_view_url=code_view_url,
download_url=download_url,
declared_license=declared_license,
primary_language=primary_language,
repository_homepage_url=cocoapods_org_url,
vcs_url=vcs_url,
**purl.to_dict(),
)
purl_pkg.version = tag

return purl_pkg
63 changes: 27 additions & 36 deletions src/fetchcode/packagedcode_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,21 @@
from __future__ import print_function
from __future__ import unicode_literals

from collections import OrderedDict
import logging
import sys
from collections import OrderedDict

import attr
from packageurl import normalize_qualifiers
from packageurl import PackageURL

from commoncode.datautils import choices
from commoncode.datautils import Boolean
from commoncode.datautils import Date
from commoncode.datautils import Integer
from commoncode.datautils import List
from commoncode.datautils import Mapping
from commoncode.datautils import String
from commoncode.datautils import TriBoolean

from commoncode.datautils import choices
from packageurl import PackageURL
from packageurl import normalize_qualifiers

"""
Data models for package information and dependencies, abstracting the
Expand Down Expand Up @@ -247,31 +245,24 @@ def purl(self):
self.type, self.namespace, self.name, self.version,
self.qualifiers, self.subpath).to_string()

def repository_homepage_url(self, baseurl=default_web_baseurl):
"""
Return the package repository homepage URL for this package, e.g. the
URL to the page for this package in its package repository. This is
typically different from the package homepage URL proper.
Subclasses should override to provide a proper value.
"""
return

def repository_download_url(self, baseurl=default_download_baseurl):
"""
Return the package repository download URL to download the actual
archive of code of this package. This may be different than the actual
download URL and is computed from the default public respoitory baseurl.
Subclasses should override to provide a proper value.
"""
return

def api_data_url(self, baseurl=default_api_baseurl):
"""
Return the package repository API URL to obtain structured data for this
package such as the URL to a JSON or XML api.
Subclasses should override to provide a proper value.
"""
return
repository_homepage_url = String(
label='package repository homepage URL.',
help='URL to the page for this package in its package repository. '
'This is typically different from the package homepage URL proper.'
)

repository_download_url = String(
label='package repository download URL.',
help='download URL to download the actual archive of code of this '
'package in its package repository. '
'This may be different from the actual download URL.'
)

api_data_url = String(
label='package repository API URL.',
help='API URL to obtain structured data for this package such as the '
'URL to a JSON or XML api its package repository.'
)

def set_purl(self, package_url):
"""
Expand All @@ -298,9 +289,9 @@ def to_dict(self, **kwargs):
mapping = attr.asdict(self, dict_factory=OrderedDict)
if not kwargs.get('exclude_properties'):
mapping['purl'] = self.purl
mapping['repository_homepage_url'] = self.repository_homepage_url()
mapping['repository_download_url'] = self.repository_download_url()
mapping['api_data_url'] = self.api_data_url()
mapping['repository_homepage_url'] = self.repository_homepage_url
mapping['repository_download_url'] = self.repository_download_url
mapping['api_data_url'] = self.api_data_url
if self.qualifiers:
mapping['qualifiers'] = normalize_qualifiers(self.qualifiers, encode=False)
return mapping
Expand Down Expand Up @@ -399,7 +390,7 @@ class Package(BasePackage):

api_url = String(
label='API URL',
help='URL of API for this package.')
help='URL of API for this package.')

size = Integer(
default=None,
Expand Down Expand Up @@ -842,4 +833,4 @@ class SquashfsPackage(Package):
default_type = 'squashfs'


# TODO: Add VM images formats(VMDK, OVA, OVF, VDI, etc) and Docker/other containers
# TODO: Add VM images formats(VMDK, OVA, OVF, VDI, etc) and Docker/other containers
75 changes: 72 additions & 3 deletions src/fetchcode/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
# CONDITIONS OF ANY KIND, either express or implied. See tshe License for the
# specific language governing permissions and limitations under the License.

import hashlib
import os
import sys
from functools import partial

import requests
from dateutil import parser as dateparser
from dateutil.parser import ParserError
Expand Down Expand Up @@ -166,11 +170,76 @@ def get_github_rest(url):


def get_response(url, headers=None):
"""
Generate `Package` object for a `url` string
"""
resp = requests.get(url, headers=headers)
if resp.status_code == 200:
return resp.json()

raise Exception(f"Failed to fetch: {url}")


def get_text_response(url, headers=None):
resp = requests.get(url, headers=headers)
if resp.status_code == 200:
return resp.text

raise Exception(f"Failed to fetch: {url}")


def make_head_request(url, headers=None):
try:
resp = requests.head(url, headers=headers)
return resp
except:
raise Exception(f"Failed to fetch: {url}")


def get_hashed_path(name):
"""
Returns a string with a part of the file path derived from the md5 hash.

From https://github.com/CocoaPods/cdn.cocoapods.org:
"There are a set of known prefixes for all Podspec paths, you take the
name of the pod, create a hash (using md5) of it and take the first
three characters."

"""
if not name:
return
podname = get_podname_proper(name)
if name != podname:
name_to_hash = podname
else:
name_to_hash = name

hash_init = get_first_three_md5_hash_characters(name_to_hash)
hashed_path = "/".join(list(hash_init))

return hashed_path


# for FIPS support
sys_v0 = sys.version_info[0]
sys_v1 = sys.version_info[1]
if sys_v0 == 3 and sys_v1 >= 9:
md5_hasher = partial(hashlib.md5, usedforsecurity=False)
else:
md5_hasher = hashlib.md5
Comment on lines +220 to +226
Copy link
Member

Choose a reason for hiding this comment

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

is this even needed? I don't think we're using FIPS enabled system.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks @keshav-space . I copied this over from https://github.com/nexB/scancode-toolkit/blob/develop/src/packagedcode/cocoapods.py#L89-L118 at the start of the project. Don't know whether this is needed or not. Shall I remove this FIPS block?

Copy link
Member Author

Choose a reason for hiding this comment

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

@pombredanne For the time being I'm leaving the get_hashed_path() function and related code from packagedcode/cocoapods.py as is in fetchcode/utils.py, though I think you'd mentioned a better way of handling this (the hash is used to get the path to a pod's podspec).

@keshav-space had raised the question of whether we need the FIPs block above. Is this needed?



def get_podname_proper(podname):
"""
Podnames in cocoapods sometimes are files inside a pods package (like 'OHHTTPStubs/Default')
This returns proper podname in those cases.
"""
if "/" in podname:
return podname.split("/")[0]
return podname


def get_first_three_md5_hash_characters(podname):
"""
From https://github.com/CocoaPods/cdn.cocoapods.org:
"There are a set of known prefixes for all Podspec paths, you take the name of the pod,
create a hash (using md5) of it and take the first three characters."
"""
return md5_hasher(podname.encode("utf-8")).hexdigest()[0:3]
Loading
Loading