1use std::any::Any;
2use tower_lsp_server::ls_types::Range;
3
4#[derive(Debug, Clone, PartialEq)]
33pub struct PypiDependency {
34 pub name: String,
36 pub name_range: Range,
38 pub version_req: Option<String>,
40 pub version_range: Option<Range>,
42 pub extras: Vec<String>,
44 pub extras_range: Option<Range>,
46 pub markers: Option<String>,
48 pub markers_range: Option<Range>,
50 pub section: PypiDependencySection,
52 pub source: PypiDependencySource,
54}
55
56#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum PypiDependencySection {
75 BuildSystem,
77 Dependencies,
79 OptionalDependencies { group: String },
81 DependencyGroup { group: String },
83 PoetryDependencies,
85 PoetryGroup { group: String },
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum PypiDependencySource {
113 PyPI,
115 Git { url: String, rev: Option<String> },
117 Path { path: String },
119 Url { url: String },
121}
122
123#[derive(Debug, Clone)]
142pub struct PypiVersion {
143 pub version: String,
145 pub yanked: bool,
147}
148
149impl PypiVersion {
150 pub fn is_prerelease(&self) -> bool {
170 use pep440_rs::Version;
171 use std::str::FromStr;
172
173 Version::from_str(&self.version)
174 .map(|v| v.is_pre())
175 .unwrap_or(false)
176 }
177}
178
179deps_core::impl_version!(PypiVersion {
181 version: version,
182 yanked: yanked,
183});
184
185#[derive(Debug, Clone)]
208pub struct PypiPackage {
209 pub name: String,
211 pub summary: Option<String>,
213 pub project_urls: Vec<(String, String)>,
215 pub latest_version: String,
217}
218
219impl deps_core::Dependency for PypiDependency {
222 fn name(&self) -> &str {
223 &self.name
224 }
225
226 fn name_range(&self) -> Range {
227 self.name_range
228 }
229
230 fn version_requirement(&self) -> Option<&str> {
231 self.version_req.as_deref()
232 }
233
234 fn version_range(&self) -> Option<Range> {
235 self.version_range
236 }
237
238 fn source(&self) -> deps_core::parser::DependencySource {
239 match &self.source {
240 PypiDependencySource::PyPI => deps_core::parser::DependencySource::Registry,
241 PypiDependencySource::Git { url, rev } => deps_core::parser::DependencySource::Git {
242 url: url.clone(),
243 rev: rev.clone(),
244 },
245 PypiDependencySource::Path { path } => {
246 deps_core::parser::DependencySource::Path { path: path.clone() }
247 }
248 PypiDependencySource::Url { .. } => deps_core::parser::DependencySource::Registry,
249 }
250 }
251
252 fn as_any(&self) -> &dyn Any {
253 self
254 }
255}
256
257impl deps_core::PackageMetadata for PypiPackage {
258 fn name(&self) -> &str {
259 &self.name
260 }
261
262 fn description(&self) -> Option<&str> {
263 self.summary.as_deref()
264 }
265
266 fn repository(&self) -> Option<&str> {
267 self.project_urls
268 .iter()
269 .find(|(key, _)| {
270 key.eq_ignore_ascii_case("repository")
271 || key.eq_ignore_ascii_case("source")
272 || key.eq_ignore_ascii_case("code")
273 })
274 .map(|(_, url)| url.as_str())
275 }
276
277 fn documentation(&self) -> Option<&str> {
278 self.project_urls
279 .iter()
280 .find(|(key, _)| {
281 key.eq_ignore_ascii_case("documentation")
282 || key.eq_ignore_ascii_case("docs")
283 || key.eq_ignore_ascii_case("homepage")
284 })
285 .map(|(_, url)| url.as_str())
286 }
287
288 fn latest_version(&self) -> &str {
289 &self.latest_version
290 }
291}
292
293impl deps_core::Metadata for PypiPackage {
294 fn name(&self) -> &str {
295 &self.name
296 }
297
298 fn description(&self) -> Option<&str> {
299 self.summary.as_deref()
300 }
301
302 fn repository(&self) -> Option<&str> {
303 self.project_urls
304 .iter()
305 .find(|(key, _)| {
306 key.eq_ignore_ascii_case("repository")
307 || key.eq_ignore_ascii_case("source")
308 || key.eq_ignore_ascii_case("code")
309 })
310 .map(|(_, url)| url.as_str())
311 }
312
313 fn documentation(&self) -> Option<&str> {
314 self.project_urls
315 .iter()
316 .find(|(key, _)| {
317 key.eq_ignore_ascii_case("documentation")
318 || key.eq_ignore_ascii_case("docs")
319 || key.eq_ignore_ascii_case("homepage")
320 })
321 .map(|(_, url)| url.as_str())
322 }
323
324 fn latest_version(&self) -> &str {
325 &self.latest_version
326 }
327
328 fn as_any(&self) -> &dyn Any {
329 self
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use deps_core::{PackageMetadata, VersionInfo};
337 use tower_lsp_server::ls_types::Position;
338
339 #[test]
340 fn test_pypi_dependency_creation() {
341 let dep = PypiDependency {
342 name: "flask".into(),
343 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
344 version_req: Some(">=3.0.0".into()),
345 version_range: Some(Range::new(Position::new(0, 6), Position::new(0, 14))),
346 extras: vec!["async".into()],
347 extras_range: None,
348 markers: Some("python_version>='3.9'".into()),
349 markers_range: None,
350 section: PypiDependencySection::Dependencies,
351 source: PypiDependencySource::PyPI,
352 };
353
354 assert_eq!(dep.name, "flask");
355 assert_eq!(dep.version_req, Some(">=3.0.0".into()));
356 assert_eq!(dep.extras, vec!["async"]);
357 }
358
359 #[test]
360 fn test_dependency_section_variants() {
361 let deps = PypiDependencySection::Dependencies;
362 let opt_deps = PypiDependencySection::OptionalDependencies {
363 group: "dev".into(),
364 };
365 let dep_group = PypiDependencySection::DependencyGroup {
366 group: "dev".into(),
367 };
368 let poetry_deps = PypiDependencySection::PoetryDependencies;
369 let poetry_group = PypiDependencySection::PoetryGroup {
370 group: "test".into(),
371 };
372
373 assert!(matches!(deps, PypiDependencySection::Dependencies));
374 assert!(matches!(
375 opt_deps,
376 PypiDependencySection::OptionalDependencies { .. }
377 ));
378 assert!(matches!(
379 dep_group,
380 PypiDependencySection::DependencyGroup { .. }
381 ));
382 assert!(matches!(
383 poetry_deps,
384 PypiDependencySection::PoetryDependencies
385 ));
386 assert!(matches!(
387 poetry_group,
388 PypiDependencySection::PoetryGroup { .. }
389 ));
390 }
391
392 #[test]
393 fn test_dependency_source_variants() {
394 let pypi = PypiDependencySource::PyPI;
395 let git = PypiDependencySource::Git {
396 url: "https://github.com/user/repo.git".into(),
397 rev: Some("main".into()),
398 };
399 let path = PypiDependencySource::Path {
400 path: "../local".into(),
401 };
402 let url = PypiDependencySource::Url {
403 url: "https://example.com/package.whl".into(),
404 };
405
406 assert!(matches!(pypi, PypiDependencySource::PyPI));
407 assert!(matches!(git, PypiDependencySource::Git { .. }));
408 assert!(matches!(path, PypiDependencySource::Path { .. }));
409 assert!(matches!(url, PypiDependencySource::Url { .. }));
410 }
411
412 #[test]
413 fn test_pypi_version_creation() {
414 let version = PypiVersion {
415 version: "1.0.0".into(),
416 yanked: false,
417 };
418
419 assert_eq!(version.version, "1.0.0");
420 assert!(!version.yanked);
421 assert!(!version.is_prerelease());
422 }
423
424 #[test]
425 fn test_pypi_version_prerelease_detection() {
426 let stable = PypiVersion {
427 version: "1.0.0".into(),
428 yanked: false,
429 };
430 let alpha = PypiVersion {
431 version: "1.0.0a1".into(),
432 yanked: false,
433 };
434 let beta = PypiVersion {
435 version: "1.0.0b2".into(),
436 yanked: false,
437 };
438 let rc = PypiVersion {
439 version: "1.0.0rc1".into(),
440 yanked: false,
441 };
442
443 assert!(!stable.is_prerelease());
444 assert!(alpha.is_prerelease());
445 assert!(beta.is_prerelease());
446 assert!(rc.is_prerelease());
447 }
448
449 #[test]
450 fn test_pypi_version_info_trait() {
451 let version = PypiVersion {
452 version: "2.28.2".into(),
453 yanked: true,
454 };
455
456 assert_eq!(version.version_string(), "2.28.2");
457 assert!(version.is_yanked());
458 }
459
460 #[test]
461 fn test_pypi_package_creation() {
462 let pkg = PypiPackage {
463 name: "requests".into(),
464 summary: Some("Python HTTP for Humans.".into()),
465 project_urls: vec![
466 ("Homepage".into(), "https://requests.readthedocs.io".into()),
467 (
468 "Repository".into(),
469 "https://github.com/psf/requests".into(),
470 ),
471 ],
472 latest_version: "2.28.2".into(),
473 };
474
475 assert_eq!(pkg.name, "requests");
476 assert_eq!(pkg.latest_version, "2.28.2");
477 }
478
479 #[test]
480 fn test_pypi_package_metadata_trait() {
481 let pkg = PypiPackage {
482 name: "flask".into(),
483 summary: Some("A micro web framework".into()),
484 project_urls: vec![
485 (
486 "Documentation".into(),
487 "https://flask.palletsprojects.com/".into(),
488 ),
489 (
490 "Repository".into(),
491 "https://github.com/pallets/flask".into(),
492 ),
493 ],
494 latest_version: "3.0.0".into(),
495 };
496
497 assert_eq!(pkg.name(), "flask");
498 assert_eq!(pkg.description(), Some("A micro web framework"));
499 assert_eq!(pkg.repository(), Some("https://github.com/pallets/flask"));
500 assert_eq!(
501 pkg.documentation(),
502 Some("https://flask.palletsprojects.com/")
503 );
504 assert_eq!(pkg.latest_version(), "3.0.0");
505 }
506
507 #[test]
508 fn test_package_url_fallbacks() {
509 let pkg = PypiPackage {
510 name: "test".into(),
511 summary: None,
512 project_urls: vec![
513 ("Homepage".into(), "https://example.com".into()),
514 ("Source".into(), "https://github.com/test/test".into()),
515 ],
516 latest_version: "1.0.0".into(),
517 };
518
519 assert_eq!(pkg.repository(), Some("https://github.com/test/test"));
521 assert_eq!(pkg.documentation(), Some("https://example.com"));
523 }
524}