Skip to content

Commit

Permalink
Merge branch 'main' into alestiago/docs-test-arguement-terminator
Browse files Browse the repository at this point in the history
  • Loading branch information
alestiago authored Oct 4, 2023
2 parents 8e06f5d + e30b629 commit 680251e
Show file tree
Hide file tree
Showing 8 changed files with 693 additions and 0 deletions.
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
74 changes: 74 additions & 0 deletions test/src/pub_license/fixtures/generate_pub_license_fixtures.dart
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

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

0 comments on commit 680251e

Please sign in to comment.