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

feat: defined PubLicense to get packages' licenses #818

Merged
merged 23 commits into from
Oct 4, 2023
Merged
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
1 change: 1 addition & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ include: package:very_good_analysis/analysis_options.5.1.0.yaml
analyzer:
exclude:
- "**/version.dart"
- "bricks/**/__brick__"
137 changes: 137 additions & 0 deletions lib/src/pub_license/pub_license.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/// Enables checking a package's license from pub.dev.
///
/// This library is intented to be used by Very Good CLI to help extracting
/// license information. The existance of this library is likely to be
/// ephemeral. It may be obsolete once [pub.dev](https://pub.dev/) exposes
/// stable license information in their official API; you may track the
/// progress [here](https://github.com/dart-lang/pub-dev/issues/4717).
library pub_license;

import 'package:html/dom.dart' as html_dom;
import 'package:html/parser.dart' as html_parser;
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

/// The pub.dev [Uri] used to retrieve the license of a package.
Uri _pubPackageLicenseUri(String packageName) =>
Uri.parse('https://pub.dev/packages/$packageName/license');

/// {@template pub_license_exception}
/// An exception thrown by [PubLicense].
/// {@endtemplate}
class PubLicenseException implements Exception {
/// {@macro pub_license_exception}
const PubLicenseException(String message)
: message = '[pub_license] $message';

/// The exception message.
final String message;
}

/// The function signature for parsing HTML documents.
@visibleForTesting
typedef HtmlDocumentParse = html_dom.Document Function(
dynamic input, {
String? encoding,
bool generateSpans,
String? sourceUrl,
});

/// {@template pub_license}
/// Enables checking pub.dev's hosted packages license.
/// {@endtemplate}
class PubLicense {
/// {@macro pub_license}
PubLicense({
@visibleForTesting http.Client? client,
@visibleForTesting HtmlDocumentParse? parse,
}) : _client = client ?? http.Client(),
_parse = parse ?? html_parser.parse;

final http.Client _client;

final html_dom.Document Function(
dynamic input, {
String? encoding,
bool generateSpans,
String? sourceUrl,
}) _parse;

/// Retrieves the license of a package.
///
/// Some packages may have multiple licenses, hence a [Set] is returned.
///
/// It may throw a [PubLicenseException] if:
/// * The response from pub.dev is not successful.
/// * The response body cannot be parsed.
Future<Set<String>> getLicense(String packageName) async {
final response = await _client.get(_pubPackageLicenseUri(packageName));

if (response.statusCode != 200) {
throw PubLicenseException(
'''Failed to retrieve the license of the package, received status code: ${response.statusCode}''',
);
}

late final html_dom.Document document;
try {
document = _parse(response.body);
} on html_parser.ParseError catch (e) {
throw PubLicenseException(
'Failed to parse the response body, received error: $e',
);
} catch (e) {
throw PubLicenseException(
'''An unknown error occurred when trying to parse the response body, received error: $e''',
);
}

return _scrapeLicense(document);
}
}

/// Scrapes the license from the pub.dev's package license page.
///
/// The expected HTML structure is:
/// ```html
/// <aside class="detail-info-box">
/// <h3> ... </h3>
/// <p> ... </p>
/// <h3 class="title">License</h3>
/// <p>
/// <img/>
/// MIT (<a href="/packages/very_good_cli/license">LICENSE</a>)
/// </p>
/// </aside>
/// ```
///
/// It may throw a [PubLicenseException] if:
/// * The detail info box is not found.
/// * The license header is not found.
Set<String> _scrapeLicense(html_dom.Document document) {
final detailInfoBox = document.querySelector('.detail-info-box');
if (detailInfoBox == null) {
throw const PubLicenseException(
'''Failed to scrape license because `.detail-info-box` was not found.''',
);
}

String? rawLicenseText;
for (var i = 0; i < detailInfoBox.children.length; i++) {
final child = detailInfoBox.children[i];

final headerText = child.text.trim().toLowerCase();
if (headerText == 'license') {
rawLicenseText = detailInfoBox.children[i + 1].text.trim();
break;
}
}
if (rawLicenseText == null) {
throw const PubLicenseException(
'''Failed to scrape license because the license header was not found.''',
);
}

final licenseText = rawLicenseText.split('(').first.trim();
return licenseText.split(',').map((e) => e.trim()).toSet();
}
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ dependencies:
cli_completion: ^0.4.0
collection: ^1.17.1
glob: ^2.0.2
html: ^0.15.4 # This dependency is temporary and should be removed once pub_license is obsolete.
http: ^1.1.0 # This dependency is temporary and should be removed once pub_license is obsolete.
lcov_parser: ^0.1.2
mason: 0.1.0-dev.51
mason_logger: ^0.2.2
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/// A small script used to generate the fixture for the pub_license test.
///
/// Fixtures are simply a temporary snapshot of an HTML response from pub.dev.
/// The generated fixtures allow testing pub_license scraping logic without
/// making a request to pub.dev every time the test is run.
///
/// To run this script, use the following command:
/// ```bash
/// dart test/src/pub_license/fixtures/generate_pub_license_fixtures.dart
/// ```
///
/// Or simply use the "Run" CodeLens from VSCode's Dart extension if running
/// from VSCode.
library generate_pub_license_fixtures;

// ignore_for_file: avoid_print
alestiago marked this conversation as resolved.
Show resolved Hide resolved

import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;

/// [Uri] used to test the case where a package has a single license.
final _singleLicenseUri = Uri.parse(
'https://pub.dev/packages/very_good_cli/license',
);

/// [Uri] used to test the case where a package has multiple licenses.
final _multipleLicenseUri = Uri.parse(
'https://pub.dev/packages/just_audio/license',
);

/// [Uri] used to test the case where a package has no license.
final _noLicenseUri = Uri.parse(
'https://pub.dev/packages/music_control_notification/license',
);

Future<void> main() async {
final fixtureUris = <String, Uri>{
'singleLicense': _singleLicenseUri,
'multipleLicense': _multipleLicenseUri,
'noLicense': _noLicenseUri,
};

final httpClient = http.Client();

for (final entry in fixtureUris.entries) {
final name = entry.key;
final uri = entry.value;

final response = await httpClient.get(uri);

if (response.statusCode != 200) {
print(
'''Failed to generate a fixture for $name, received status code: ${response.statusCode}''',
);
continue;
}

final fixturePath = path.joinAll([
Directory.current.path,
'test',
'src',
'pub_license',
'fixtures',
'$name.html',
]);
File(fixturePath)
..createSync(recursive: true)
..writeAsStringSync(response.body);

print('Fixture generated at $fixturePath');
}
}
Loading