1use std::any::Any;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{CompletionItem, DocumentLink, Position, Range, Uri};
11
12use deps_core::{
13 Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
14 lsp_helpers::EcosystemFormatter,
15};
16
17use crate::formatter::PypiFormatter;
18use crate::parser::PypiParser;
19use crate::registry::PypiRegistry;
20
21#[derive(Clone, Copy)]
25enum PypiManifestKind {
26 PyProject,
27 Requirements,
28}
29
30impl PypiManifestKind {
31 fn from_uri(uri: &Uri) -> Self {
32 let basename = uri.path().as_str().rsplit('/').next().unwrap_or_default();
33 if basename == "pyproject.toml" {
34 Self::PyProject
35 } else {
36 Self::Requirements
37 }
38 }
39
40 const fn file_type(self) -> &'static str {
41 match self {
42 Self::PyProject => "pyproject.toml",
43 Self::Requirements => "requirements.txt",
44 }
45 }
46}
47
48pub struct PypiEcosystem {
58 registry: Arc<PypiRegistry>,
59 parser: PypiParser,
60 formatter: PypiFormatter,
61 policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
69}
70
71impl PypiEcosystem {
72 pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
76 Self::with_policy(
77 Arc::new(PypiRegistry::new(cache)),
78 Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
79 )
80 }
81
82 #[must_use]
88 pub fn with_policy(
89 registry: Arc<PypiRegistry>,
90 policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
91 ) -> Self {
92 Self {
93 registry,
94 parser: PypiParser::new(),
95 formatter: PypiFormatter,
96 policy,
97 }
98 }
99
100 async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
101 let mut items = deps_core::completion::complete_package_names_generic(
102 self.registry.as_ref(),
103 prefix,
104 20,
105 range,
106 )
107 .await;
108
109 for item in &mut items {
125 item.filter_text = Some(prefix.to_string());
126 }
127
128 items
129 }
130
131 fn matched_only_via_directory_pattern(&self, uri: &Uri) -> bool {
146 let basename = uri.path().as_str().rsplit('/').next().unwrap_or_default();
147 if self.manifest_filenames().contains(&basename) {
148 return false;
149 }
150 !self.manifest_patterns().iter().any(|pattern| {
151 deps_core::ecosystem_registry::manifest_pattern_matches(basename, pattern)
152 })
153 }
154
155 async fn complete_versions(
172 &self,
173 parse_result: &dyn ParseResultTrait,
174 position: Position,
175 prefix: &str,
176 freshness: deps_core::FreshnessSettings,
177 ) -> Vec<CompletionItem> {
178 deps_core::completion::complete_versions_at_position(
179 self.registry.as_ref(),
180 &self.formatter,
181 parse_result,
182 position,
183 prefix,
184 &['>', '<', '=', '~', '!'],
185 freshness,
186 )
187 .await
188 }
189}
190
191impl deps_core::ecosystem::private::Sealed for PypiEcosystem {}
192
193impl Ecosystem for PypiEcosystem {
194 fn id(&self) -> &'static str {
195 "pypi"
196 }
197
198 fn display_name(&self) -> &'static str {
199 "PyPI (Python)"
200 }
201
202 fn manifest_filenames(&self) -> &[&'static str] {
203 &["pyproject.toml"]
204 }
205
206 fn manifest_patterns(&self) -> &[&'static str] {
207 &[
208 "requirements*.txt",
209 "*-requirements.txt",
210 "*.requirements.txt",
211 "constraints*.txt",
212 ]
213 }
214
215 fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
216 &[("requirements", ".txt")]
217 }
218
219 fn lockfile_filenames(&self) -> &[&'static str] {
220 &["poetry.lock", "uv.lock"]
221 }
222
223 fn parse_manifest<'a>(
224 &'a self,
225 content: &'a str,
226 uri: &'a Uri,
227 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
228 Box::pin(async move {
229 let kind = PypiManifestKind::from_uri(uri);
230 let result = match kind {
231 PypiManifestKind::PyProject => {
232 self.parser
233 .parse_content_with_policy(content, uri, &self.policy)
234 }
235 PypiManifestKind::Requirements => {
236 let require_strong_signal = self.matched_only_via_directory_pattern(uri);
237 self.parser.parse_requirements_with_policy(
238 content,
239 uri,
240 require_strong_signal,
241 &self.policy,
242 )
243 }
244 }
245 .map_err(|e| deps_core::DepsError::ParseError {
246 file_type: kind.file_type().into(),
247 source: Box::new(e),
248 })?;
249 for chain in &result.resolved_chains {
261 PypiRegistry::register_chain(&self.registry, chain);
262 }
263 Ok(Box::new(result) as Box<dyn ParseResultTrait>)
264 })
265 }
266
267 fn registry(&self) -> Arc<dyn Registry> {
268 self.registry.clone() as Arc<dyn Registry>
269 }
270
271 fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
272 Some(Arc::new(crate::lockfile::PypiLockParser))
273 }
274
275 fn formatter(&self) -> &dyn EcosystemFormatter {
276 &self.formatter
277 }
278
279 fn generate_completions<'a>(
280 &'a self,
281 parse_result: &'a dyn ParseResultTrait,
282 position: Position,
283 content: &'a str,
284 freshness: deps_core::FreshnessSettings,
285 ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
286 Box::pin(async move {
287 use deps_core::completion::{CompletionContext, detect_completion_context};
288
289 self.registry.warm_search_index();
296
297 let context = detect_completion_context(parse_result, position, content);
298
299 match context {
300 CompletionContext::PackageName { prefix, range } => Completions {
307 items: self.complete_package_names(&prefix, range).await,
308 is_incomplete: true,
309 },
310 CompletionContext::Version { prefix, .. } => self
311 .complete_versions(parse_result, position, &prefix, freshness)
312 .await
313 .into(),
314 CompletionContext::Feature { .. } | CompletionContext::None => {
315 Completions::default()
316 }
317 }
318 })
319 }
320
321 fn package_search_is_incomplete(&self) -> bool {
322 true
327 }
328
329 fn generate_document_links(
330 &self,
331 parse_result: &dyn ParseResultTrait,
332 uri: &Uri,
333 ) -> Vec<DocumentLink> {
334 let Some(result) = parse_result
335 .as_any()
336 .downcast_ref::<crate::parser::ParseResult>()
337 else {
338 return Vec::new();
339 };
340 if result.document_links.is_empty() {
341 return Vec::new();
342 }
343
344 let Some(base_dir) = uri
345 .to_file_path()
346 .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
347 else {
348 return Vec::new();
349 };
350
351 result
352 .document_links
353 .iter()
354 .filter_map(|link| {
355 if link.target.contains("://") {
359 return None;
360 }
361 if !is_safe_document_link_target(&link.target) {
362 deps_core::lsp_helpers::warn_rejected_value(
363 "is_safe_document_link_target",
364 "pypi requirements -r/-c document link target",
365 &link.target,
366 );
367 return None;
368 }
369 let target_path = base_dir.join(&link.target);
370 let target_uri = Uri::from_file_path(&target_path)?;
371 let tooltip = target_uri.to_file_path()?.display().to_string();
377 Some(DocumentLink {
378 range: link.range,
379 target: Some(target_uri),
380 tooltip: Some(tooltip),
385 data: None,
386 })
387 })
388 .collect()
389 }
390
391 fn as_any(&self) -> &dyn Any {
392 self
393 }
394}
395
396fn is_safe_document_link_target(target: &str) -> bool {
409 !target.is_empty()
410 && target.chars().all(|c| {
411 !c.is_control()
412 && !matches!(c,
413 '\u{200B}'..='\u{200F}'
414 | '\u{202A}'..='\u{202E}'
415 | '\u{2060}'
416 | '\u{2066}'..='\u{2069}'
417 | '\u{2028}'
418 | '\u{2029}'
419 | '\u{FEFF}'
420 )
421 })
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use deps_core::{EcosystemConfig, VersionData, parser::DependencySource};
428 use std::assert_matches;
429 use std::collections::HashMap;
430
431 fn pkg(s: &str) -> deps_core::PackageName {
432 deps_core::PackageName::new(s)
433 }
434
435 #[test]
436 fn test_ecosystem_id() {
437 let cache = Arc::new(deps_core::HttpCache::new());
438 let ecosystem = PypiEcosystem::new(cache);
439 assert_eq!(ecosystem.id(), "pypi");
440 }
441
442 #[test]
443 fn test_ecosystem_manifest_patterns() {
444 let cache = Arc::new(deps_core::HttpCache::new());
445 let ecosystem = PypiEcosystem::new(cache);
446 assert_eq!(
447 ecosystem.manifest_patterns(),
448 &[
449 "requirements*.txt",
450 "*-requirements.txt",
451 "*.requirements.txt",
452 "constraints*.txt",
453 ]
454 );
455 }
456
457 #[test]
458 fn test_matched_only_via_directory_pattern() {
459 let cache = Arc::new(deps_core::HttpCache::new());
460 let ecosystem = PypiEcosystem::new(cache);
461
462 let uri = deps_core::test_util::test_uri("/project/requirements/base.txt");
464 assert!(ecosystem.matched_only_via_directory_pattern(&uri));
465
466 for path in [
469 "/project/requirements.txt",
470 "/project/requirements/dev-requirements.txt",
471 ] {
472 let uri = deps_core::test_util::test_uri(path);
473 assert!(
474 !ecosystem.matched_only_via_directory_pattern(&uri),
475 "{path} should be a basename match, not directory-pattern-only"
476 );
477 }
478 }
479
480 #[tokio::test]
481 async fn test_parse_manifest_directory_pattern_only_applies_strict_gate() {
482 let cache = Arc::new(deps_core::HttpCache::new());
485 let ecosystem = PypiEcosystem::new(cache);
486 let uri = deps_core::test_util::test_uri("/project/requirements/base.txt");
487
488 let result = ecosystem
489 .parse_manifest(
490 "Introduction\n\nScope\n\nThis document defines the requirements.\n",
491 &uri,
492 )
493 .await
494 .unwrap();
495
496 assert!(result.dependencies().is_empty());
497 }
498
499 #[tokio::test]
500 async fn test_parse_manifest_requirements_txt_uri_yields_dependencies() {
501 let cache = Arc::new(deps_core::HttpCache::new());
502 let ecosystem = PypiEcosystem::new(cache);
503 let uri = deps_core::test_util::test_uri("/test/requirements.txt");
504
505 let result = ecosystem
506 .parse_manifest("requests==2.31.0\nflask>=3.0\n", &uri)
507 .await
508 .unwrap();
509
510 assert_eq!(result.dependencies().len(), 2);
511 }
512
513 #[tokio::test]
514 async fn test_generate_document_links_resolves_relative_target() {
515 let cache = Arc::new(deps_core::HttpCache::new());
516 let ecosystem = PypiEcosystem::new(cache);
517 let uri = deps_core::test_util::test_uri("/project/requirements.txt");
518
519 let parse_result = ecosystem
520 .parse_manifest("-r base.txt\n", &uri)
521 .await
522 .unwrap();
523
524 let links = ecosystem.generate_document_links(parse_result.as_ref(), &uri);
525 assert_eq!(links.len(), 1);
526 let target = links[0].target.as_ref().unwrap();
527 assert!(target.path().as_str().ends_with("/project/base.txt"));
528 assert_eq!(
529 links[0].tooltip.as_deref(),
530 target.to_file_path().unwrap().to_str()
531 );
532 }
533
534 #[tokio::test]
535 async fn test_generate_document_links_rejects_bidi_override_target() {
536 let cache = Arc::new(deps_core::HttpCache::new());
540 let ecosystem = PypiEcosystem::new(cache);
541 let uri = deps_core::test_util::test_uri("/project/requirements.txt");
542
543 let parse_result = ecosystem
544 .parse_manifest("-r safe.txt\u{202E}txt.evil\n", &uri)
545 .await
546 .unwrap();
547
548 let links = ecosystem.generate_document_links(parse_result.as_ref(), &uri);
549 assert!(links.is_empty());
550 }
551
552 #[test]
553 fn test_is_safe_document_link_target_rejects_invisible_unicode() {
554 for bad in [
555 "safe.txt\u{202E}txt.evil",
556 "a\u{200B}b.txt",
557 "a\u{2028}b.txt",
558 "a\u{FEFF}b.txt",
559 "a\nb.txt",
560 ] {
561 assert!(
562 !is_safe_document_link_target(bad),
563 "expected {bad:?} to be rejected"
564 );
565 }
566 }
567
568 #[test]
569 fn test_is_safe_document_link_target_accepts_normal_paths() {
570 for good in [
571 "base.txt",
572 "../shared/constraints.txt",
573 "dev-requirements.txt",
574 ] {
575 assert!(is_safe_document_link_target(good));
576 }
577 }
578
579 #[tokio::test]
580 async fn test_parse_manifest_pyproject_toml_unchanged() {
581 let cache = Arc::new(deps_core::HttpCache::new());
582 let ecosystem = PypiEcosystem::new(cache);
583 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
584
585 let result = ecosystem
586 .parse_manifest("[project]\ndependencies = [\"requests>=2.0.0\"]\n", &uri)
587 .await
588 .unwrap();
589
590 assert_eq!(result.dependencies().len(), 1);
591 }
592
593 #[test]
594 fn test_manifest_kind_file_type_reflects_uri() {
595 let requirements_uri = deps_core::test_util::test_uri("/test/requirements.txt");
596 assert_eq!(
597 PypiManifestKind::from_uri(&requirements_uri).file_type(),
598 "requirements.txt"
599 );
600
601 let pyproject_uri = deps_core::test_util::test_uri("/test/pyproject.toml");
602 assert_eq!(
603 PypiManifestKind::from_uri(&pyproject_uri).file_type(),
604 "pyproject.toml"
605 );
606 }
607
608 #[tokio::test]
609 async fn test_parse_manifest_pyproject_toml_invalid_reports_pyproject_file_type() {
610 let cache = Arc::new(deps_core::HttpCache::new());
611 let ecosystem = PypiEcosystem::new(cache);
612 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
613
614 let result = ecosystem
615 .parse_manifest("[project\nname = invalid", &uri)
616 .await;
617
618 let Err(err) = result else {
619 panic!("expected a parse error");
620 };
621 assert_matches!(
622 err,
623 deps_core::DepsError::ParseError { file_type, .. } if file_type == "pyproject.toml"
624 );
625 }
626
627 #[test]
628 fn test_ecosystem_display_name() {
629 let cache = Arc::new(deps_core::HttpCache::new());
630 let ecosystem = PypiEcosystem::new(cache);
631 assert_eq!(ecosystem.display_name(), "PyPI (Python)");
632 }
633
634 #[test]
635 fn test_ecosystem_manifest_filenames() {
636 let cache = Arc::new(deps_core::HttpCache::new());
637 let ecosystem = PypiEcosystem::new(cache);
638 assert_eq!(ecosystem.manifest_filenames(), &["pyproject.toml"]);
639 }
640
641 #[test]
642 fn test_ecosystem_lockfile_filenames() {
643 let cache = Arc::new(deps_core::HttpCache::new());
644 let ecosystem = PypiEcosystem::new(cache);
645 assert_eq!(ecosystem.lockfile_filenames(), &["poetry.lock", "uv.lock"]);
646 }
647
648 #[test]
649 fn test_as_any() {
650 let cache = Arc::new(deps_core::HttpCache::new());
651 let ecosystem = PypiEcosystem::new(cache);
652
653 let any = ecosystem.as_any();
654 assert!(any.is::<PypiEcosystem>());
655 }
656
657 #[tokio::test]
658 async fn test_package_name_completion_context_has_real_range() {
659 let cache = Arc::new(deps_core::HttpCache::new());
662 let ecosystem = PypiEcosystem::new(cache);
663 let content = "[dependency-groups]\ndev = [\"pytest>=8.0\", \"mypy>=1.0\"]\n";
664 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
665
666 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
667 let position = Position::new(1, 11); let context = deps_core::completion::detect_completion_context(
670 parse_result.as_ref(),
671 position,
672 content,
673 );
674
675 match context {
676 deps_core::completion::CompletionContext::PackageName { prefix, range } => {
677 assert_eq!(prefix, "pyt");
678 assert_ne!(range, Range::default());
679 assert_eq!(range, Range::new(Position::new(1, 8), Position::new(1, 14)));
680 }
681 other => panic!("Expected PackageName context, got {other:?}"),
682 }
683 }
684
685 #[tokio::test]
694 async fn test_generate_completions_package_name_context_is_incomplete() {
695 let cache = Arc::new(deps_core::HttpCache::new());
696 let ecosystem = PypiEcosystem::new(cache);
697 let content = "[dependency-groups]\ndev = [\"pytest>=8.0\", \"mypy>=1.0\"]\n";
698 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
699
700 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
701 let position = Position::new(1, 11); let completions = ecosystem
704 .generate_completions(
705 parse_result.as_ref(),
706 position,
707 content,
708 deps_core::FreshnessSettings::default(),
709 )
710 .await;
711
712 assert!(
713 completions.is_incomplete,
714 "PackageName context must report is_incomplete: true, even with zero \
715 items on a cold-start index"
716 );
717 }
718
719 #[tokio::test]
720 async fn test_complete_package_names_minimum_prefix() {
721 let cache = Arc::new(deps_core::HttpCache::new());
722 let ecosystem = PypiEcosystem::new(cache);
723
724 let results = ecosystem
726 .complete_package_names("d", Range::default())
727 .await;
728 assert!(results.is_empty());
729
730 let results = ecosystem.complete_package_names("", Range::default()).await;
732 assert!(results.is_empty());
733 }
734
735 fn ecosystem_with_index_url(
739 cache: Arc<deps_core::HttpCache>,
740 index_url: String,
741 ) -> PypiEcosystem {
742 PypiEcosystem {
743 registry: Arc::new(PypiRegistry::with_index_url(cache, index_url)),
744 parser: PypiParser::new(),
745 formatter: PypiFormatter,
746 policy: Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
747 }
748 }
749
750 async fn poll_until_nonempty<F, Fut>(mut probe: F, attempts: u32) -> Vec<CompletionItem>
754 where
755 F: FnMut() -> Fut,
756 Fut: std::future::Future<Output = Vec<CompletionItem>>,
757 {
758 for _ in 0..attempts {
759 let results = probe().await;
760 if !results.is_empty() {
761 return results;
762 }
763 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
764 }
765 probe().await
766 }
767
768 #[tokio::test]
774 async fn test_complete_package_names_uses_index() {
775 let mut server = mockito::Server::new_async().await;
776 let _mock = server
777 .mock("GET", "/simple/")
778 .with_status(200)
779 .with_body(crate::search::sample_index_body(&["requests"]))
780 .create_async()
781 .await;
782
783 let cache = Arc::new(deps_core::HttpCache::new());
784 let index_url = format!("{}/simple/", server.url());
785 let ecosystem = ecosystem_with_index_url(cache, index_url);
786
787 let cold_start = ecosystem
788 .complete_package_names("reque", Range::default())
789 .await;
790 assert!(
791 cold_start.is_empty(),
792 "cold start must not block on the download"
793 );
794
795 let results = poll_until_nonempty(
796 || ecosystem.complete_package_names("reque", Range::default()),
797 100,
798 )
799 .await;
800 assert!(!results.is_empty());
801 assert!(results.iter().any(|r| r.label == "requests"));
802 }
803
804 #[tokio::test]
811 async fn test_complete_package_names_filter_text_matches_raw_typed_prefix() {
812 let mut server = mockito::Server::new_async().await;
813 let _mock = server
814 .mock("GET", "/simple/")
815 .with_status(200)
816 .with_body(crate::search::sample_index_body(&["zope-interface"]))
817 .create_async()
818 .await;
819
820 let cache = Arc::new(deps_core::HttpCache::new());
821 let index_url = format!("{}/simple/", server.url());
822 let ecosystem = ecosystem_with_index_url(cache, index_url);
823
824 let results = poll_until_nonempty(
825 || ecosystem.complete_package_names("zope.int", Range::default()),
826 100,
827 )
828 .await;
829
830 let item = results
831 .iter()
832 .find(|r| r.label == "zope-interface")
833 .expect("zope-interface should be found via separator-normalized search");
834 assert_eq!(
835 item.filter_text,
836 Some("zope.int".to_string()),
837 "filter_text must be the raw typed prefix, not the normalized label"
838 );
839 }
840
841 #[tokio::test]
847 async fn test_version_completion_triggers_exactly_one_index_build_attempt() {
848 let mut server = mockito::Server::new_async().await;
849 let mock = server
850 .mock("GET", "/simple/")
851 .with_status(200)
852 .with_body(crate::search::sample_index_body(&["requests"]))
853 .expect(1)
854 .create_async()
855 .await;
856
857 let cache = Arc::new(deps_core::HttpCache::new());
858 let index_url = format!("{}/simple/", server.url());
859 let ecosystem = ecosystem_with_index_url(cache, index_url);
860
861 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
862 let content = "[project]\ndependencies = [\"requests>=2.0\"]\n";
863 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
864
865 let version_line = content.lines().nth(1).unwrap();
869 let version_position = (0..=version_line.len() as u32)
870 .map(|character| tower_lsp_server::ls_types::Position::new(1, character))
871 .find(|&position| {
872 matches!(
873 deps_core::completion::detect_completion_context(
874 parse_result.as_ref(),
875 position,
876 content,
877 ),
878 deps_core::completion::CompletionContext::Version { .. }
879 )
880 })
881 .expect("fixture line must contain a Version completion context");
882
883 let mut last_completions = None;
884 for _ in 0..3 {
885 last_completions = Some(
886 ecosystem
887 .generate_completions(
888 parse_result.as_ref(),
889 version_position,
890 content,
891 deps_core::FreshnessSettings::default(),
892 )
893 .await,
894 );
895 }
896 assert!(
897 !last_completions
898 .expect("loop ran at least once")
899 .is_incomplete,
900 "a Version completion context is always exhaustive, unlike PackageName's \
901 truncated index search"
902 );
903
904 let ready = poll_until_nonempty(
906 || ecosystem.complete_package_names("reque", Range::default()),
907 100,
908 )
909 .await;
910 assert!(
911 ready.iter().any(|r| r.label == "requests"),
912 "index should be ready and contain requests after warming"
913 );
914 mock.assert_async().await;
915 }
916
917 #[tokio::test]
918 #[ignore] async fn test_complete_versions_real() {
920 let cache = Arc::new(deps_core::HttpCache::new());
921 let ecosystem = PypiEcosystem::new(cache);
922 let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
923
924 let results = ecosystem
925 .complete_versions(
926 &parse_result,
927 DEP_POSITION,
928 "2.",
929 deps_core::FreshnessSettings::default(),
930 )
931 .await;
932 assert!(!results.is_empty());
933 assert!(results.iter().all(|r| r.label.starts_with("2.")));
934 }
935
936 #[tokio::test]
937 #[ignore] async fn test_complete_versions_with_operator() {
939 let cache = Arc::new(deps_core::HttpCache::new());
940 let ecosystem = PypiEcosystem::new(cache);
941 let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
942
943 let results = ecosystem
944 .complete_versions(
945 &parse_result,
946 DEP_POSITION,
947 ">=2.",
948 deps_core::FreshnessSettings::default(),
949 )
950 .await;
951 assert!(!results.is_empty());
952 assert!(results.iter().all(|r| r.label.starts_with("2.")));
953 }
954
955 #[tokio::test]
956 async fn test_complete_versions_unknown_package() {
957 let cache = Arc::new(deps_core::HttpCache::new());
958 let ecosystem = PypiEcosystem::new(cache);
959 let parse_result = parse_result_with_dependency(
960 "this-package-does-not-exist-12345",
961 DependencySource::Registry,
962 );
963
964 let results = ecosystem
966 .complete_versions(
967 &parse_result,
968 DEP_POSITION,
969 "1.0",
970 deps_core::FreshnessSettings::default(),
971 )
972 .await;
973 assert!(results.is_empty());
974 }
975
976 const DEP_POSITION: Position = Position {
980 line: 0,
981 character: 0,
982 };
983
984 fn parse_result_with_dependency(
988 name: &str,
989 source: DependencySource,
990 ) -> crate::parser::ParseResult {
991 use tower_lsp_server::ls_types::Range;
992 crate::parser::ParseResult {
993 dependencies: vec![crate::types::PypiDependency {
994 name: pkg(name),
995 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
996 version_req: None,
997 version_range: Some(Range::new(DEP_POSITION, Position::new(0, 10))),
998 extras: Vec::new(),
999 extras_range: None,
1000 markers: None,
1001 markers_range: None,
1002 section: crate::types::PypiDependencySection::Requirements,
1003 source,
1004 }],
1005 workspace_root: None,
1006 uri: deps_core::test_util::test_uri("/test/requirements.txt"),
1007 document_links: Vec::new(),
1008 resolved_chains: Vec::new(),
1009 }
1010 }
1011
1012 #[tokio::test]
1017 async fn test_complete_versions_alternate_registry_routes_through_chain() {
1018 let mut alt_server = mockito::Server::new_async().await;
1019 let alt_mock = alt_server
1020 .mock("GET", "/simple/mypkg/")
1021 .with_status(200)
1022 .with_body(r#"{"versions": ["1.0.0", "2.0.0"], "files": []}"#)
1023 .create_async()
1024 .await;
1025
1026 let cache = Arc::new(deps_core::HttpCache::new());
1027 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
1028 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1029 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1030 deps_core::net_policy::WorkspaceRegistryAccess::All,
1031 ));
1032 let ecosystem = PypiEcosystem::with_policy(Arc::clone(&root), policy);
1033
1034 let base = crate::config::PypiIndexUrl::new(
1035 &format!("{}/simple", alt_server.url()),
1036 &deps_core::net_policy::RegistryAccessPolicy::new(
1037 deps_core::net_policy::WorkspaceRegistryAccess::All,
1038 ),
1039 )
1040 .unwrap();
1041 let chain = crate::config::ResolvedChain {
1042 key: "test-alt-chain".to_string(),
1043 hops: vec![base],
1044 implicit_public_fallback: false,
1045 };
1046 PypiRegistry::register_chain(&root, &chain);
1047
1048 let source = DependencySource::AlternateRegistry {
1049 index: chain.key.clone(),
1050 mirrors_crates_io: false,
1051 };
1052 let parse_result = parse_result_with_dependency("mypkg", source);
1053
1054 let results = ecosystem
1055 .complete_versions(
1056 &parse_result,
1057 DEP_POSITION,
1058 "",
1059 deps_core::FreshnessSettings::default(),
1060 )
1061 .await;
1062 assert!(
1063 !results.is_empty(),
1064 "expected version completions fetched from the alternate index"
1065 );
1066 alt_mock.assert_async().await;
1067 }
1068
1069 #[tokio::test]
1074 async fn test_complete_versions_custom_registry_offers_nothing() {
1075 let cache = Arc::new(deps_core::HttpCache::new());
1076 let ecosystem = PypiEcosystem::new(cache);
1077
1078 let source = DependencySource::CustomRegistry {
1079 url: "not-a-valid-url".to_string(),
1080 };
1081 let parse_result = parse_result_with_dependency("mypkg", source);
1082
1083 let results = ecosystem
1084 .complete_versions(
1085 &parse_result,
1086 DEP_POSITION,
1087 "",
1088 deps_core::FreshnessSettings::default(),
1089 )
1090 .await;
1091 assert!(results.is_empty());
1092 }
1093
1094 #[tokio::test]
1098 async fn test_complete_versions_unregistered_alternate_offers_nothing() {
1099 let cache = Arc::new(deps_core::HttpCache::new());
1100 let ecosystem = PypiEcosystem::new(cache);
1101
1102 let source = DependencySource::AlternateRegistry {
1103 index: "pypi-chain:never-registered".to_string(),
1104 mirrors_crates_io: false,
1105 };
1106 let parse_result = parse_result_with_dependency("mypkg", source);
1107
1108 let results = ecosystem
1109 .complete_versions(
1110 &parse_result,
1111 DEP_POSITION,
1112 "",
1113 deps_core::FreshnessSettings::default(),
1114 )
1115 .await;
1116 assert!(results.is_empty());
1117 }
1118
1119 #[tokio::test]
1124 async fn test_complete_versions_same_name_different_sources_routes_by_position() {
1125 let cache = Arc::new(deps_core::HttpCache::new());
1126 let ecosystem = PypiEcosystem::new(cache);
1127
1128 let mut registry_dep =
1129 parse_result_with_dependency("shared-name", DependencySource::Registry)
1130 .dependencies
1131 .remove(0);
1132 registry_dep.name_range = Range::new(Position::new(0, 0), Position::new(0, 0));
1133 registry_dep.version_range = Some(Range::new(Position::new(0, 0), Position::new(0, 10)));
1134
1135 let mut alternate_dep = parse_result_with_dependency(
1136 "shared-name",
1137 DependencySource::AlternateRegistry {
1138 index: "pypi-chain:never-registered".to_string(),
1139 mirrors_crates_io: false,
1140 },
1141 )
1142 .dependencies
1143 .remove(0);
1144 alternate_dep.name_range = Range::new(Position::new(1, 0), Position::new(1, 0));
1145 alternate_dep.version_range = Some(Range::new(Position::new(1, 0), Position::new(1, 10)));
1146 let alternate_position = alternate_dep.version_range.unwrap().start;
1147
1148 let parse_result = crate::parser::ParseResult {
1149 dependencies: vec![registry_dep, alternate_dep],
1150 workspace_root: None,
1151 uri: deps_core::test_util::test_uri("/test/requirements.txt"),
1152 document_links: Vec::new(),
1153 resolved_chains: Vec::new(),
1154 };
1155
1156 let results = ecosystem
1161 .complete_versions(
1162 &parse_result,
1163 alternate_position,
1164 "1",
1165 deps_core::FreshnessSettings::default(),
1166 )
1167 .await;
1168 assert!(
1169 results.is_empty(),
1170 "unregistered alternate chain must offer no completions"
1171 );
1172 }
1173
1174 #[tokio::test]
1175 async fn test_complete_package_names_special_characters() {
1176 let cache = Arc::new(deps_core::HttpCache::new());
1177 let ecosystem = PypiEcosystem::new(cache);
1178
1179 let results = ecosystem
1181 .complete_package_names("scikit-le", Range::default())
1182 .await;
1183 assert!(results.is_empty() || !results.is_empty());
1185 }
1186
1187 #[tokio::test]
1188 async fn test_complete_package_names_max_length() {
1189 let cache = Arc::new(deps_core::HttpCache::new());
1190 let ecosystem = PypiEcosystem::new(cache);
1191
1192 let long_prefix = "a".repeat(201);
1194 let results = ecosystem
1195 .complete_package_names(&long_prefix, Range::default())
1196 .await;
1197 assert!(results.is_empty());
1198
1199 let max_prefix = "a".repeat(100);
1201 let results = ecosystem
1202 .complete_package_names(&max_prefix, Range::default())
1203 .await;
1204 assert!(results.is_empty() || !results.is_empty());
1206 }
1207
1208 #[tokio::test]
1209 #[ignore] async fn test_complete_versions_limit_20() {
1211 let cache = Arc::new(deps_core::HttpCache::new());
1212 let ecosystem = PypiEcosystem::new(cache);
1213
1214 let parse_result = parse_result_with_dependency("requests", DependencySource::Registry);
1216 let results = ecosystem
1217 .complete_versions(
1218 &parse_result,
1219 DEP_POSITION,
1220 "2",
1221 deps_core::FreshnessSettings::default(),
1222 )
1223 .await;
1224 assert!(results.len() <= 20);
1225 }
1226
1227 #[tokio::test]
1228 #[ignore] async fn test_complete_package_names_special_chars_real() {
1230 let cache = Arc::new(deps_core::HttpCache::new());
1231 let ecosystem = PypiEcosystem::new(cache);
1232
1233 let results = ecosystem
1235 .complete_package_names("scikit-le", Range::default())
1236 .await;
1237 assert!(!results.is_empty() || results.is_empty()); }
1239
1240 #[tokio::test]
1241 async fn test_parse_manifest_valid_content() {
1242 let cache = Arc::new(deps_core::HttpCache::new());
1243 let ecosystem = PypiEcosystem::new(cache);
1244 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1245
1246 let content = r#"[project]
1247name = "test"
1248dependencies = ["requests>=2.0.0"]
1249"#;
1250
1251 let result = ecosystem.parse_manifest(content, &uri).await;
1252 assert!(result.is_ok());
1253
1254 let parse_result = result.unwrap();
1255 assert!(!parse_result.dependencies().is_empty());
1256 }
1257
1258 #[tokio::test]
1259 async fn test_parse_manifest_invalid_toml() {
1260 let cache = Arc::new(deps_core::HttpCache::new());
1261 let ecosystem = PypiEcosystem::new(cache);
1262 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1263
1264 let invalid_content = "[project\nname = invalid";
1265
1266 let result = ecosystem.parse_manifest(invalid_content, &uri).await;
1267 assert!(result.is_err());
1268 }
1269
1270 #[tokio::test]
1271 async fn test_parse_manifest_empty_dependencies() {
1272 let cache = Arc::new(deps_core::HttpCache::new());
1273 let ecosystem = PypiEcosystem::new(cache);
1274 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1275
1276 let content = r#"[project]
1277name = "test"
1278dependencies = []
1279"#;
1280
1281 let result = ecosystem.parse_manifest(content, &uri).await;
1282 assert!(result.is_ok());
1283
1284 let parse_result = result.unwrap();
1285 assert!(parse_result.dependencies().is_empty());
1286 }
1287
1288 #[tokio::test]
1289 async fn test_registry_returns_arc() {
1290 let cache = Arc::new(deps_core::HttpCache::new());
1291 let ecosystem = PypiEcosystem::new(cache);
1292
1293 let registry = ecosystem.registry();
1294 assert!(Arc::strong_count(®istry) >= 1);
1295 }
1296
1297 #[tokio::test]
1298 async fn test_lockfile_provider_returns_some() {
1299 let cache = Arc::new(deps_core::HttpCache::new());
1300 let ecosystem = PypiEcosystem::new(cache);
1301
1302 let provider = ecosystem.lockfile_provider();
1303 assert!(provider.is_some());
1304 }
1305
1306 #[tokio::test]
1307 async fn test_generate_inlay_hints_empty_dependencies() {
1308 let cache = Arc::new(deps_core::HttpCache::new());
1309 let ecosystem = PypiEcosystem::new(cache);
1310 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1311
1312 let content = r"[project]
1313dependencies = []
1314";
1315
1316 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1317 let cached_versions = HashMap::new();
1318 let resolved_versions = HashMap::new();
1319 let config = EcosystemConfig::default();
1320
1321 let hints = ecosystem
1322 .generate_inlay_hints(
1323 parse_result.as_ref(),
1324 VersionData::new(&cached_versions, &resolved_versions),
1325 deps_core::LoadingState::Loaded,
1326 &config,
1327 )
1328 .await;
1329
1330 assert!(hints.is_empty());
1331 }
1332
1333 #[tokio::test]
1334 async fn test_generate_completions_no_context() {
1335 let cache = Arc::new(deps_core::HttpCache::new());
1336 let ecosystem = PypiEcosystem::new(cache);
1337 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1338
1339 let content = r#"[project]
1340name = "test"
1341"#;
1342
1343 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1344 let position = Position {
1345 line: 0,
1346 character: 0,
1347 };
1348
1349 let completions = ecosystem
1350 .generate_completions(
1351 parse_result.as_ref(),
1352 position,
1353 content,
1354 deps_core::FreshnessSettings::default(),
1355 )
1356 .await;
1357
1358 assert!(completions.items.is_empty());
1359 assert!(!completions.is_incomplete);
1360 }
1361
1362 #[tokio::test]
1363 async fn test_generate_completions_feature_context_returns_empty() {
1364 let cache = Arc::new(deps_core::HttpCache::new());
1365 let ecosystem = PypiEcosystem::new(cache);
1366
1367 let content = r#"[project]
1371dependencies = ["requests"]
1372"#;
1373 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1374 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1375
1376 let position = Position {
1378 line: 1,
1379 character: 20,
1380 };
1381
1382 let completions = ecosystem
1383 .generate_completions(
1384 parse_result.as_ref(),
1385 position,
1386 content,
1387 deps_core::FreshnessSettings::default(),
1388 )
1389 .await;
1390
1391 assert!(completions.items.is_empty() || !completions.items.is_empty());
1393 }
1394
1395 #[tokio::test]
1396 async fn test_generate_hover_no_dependency_at_position() {
1397 let cache = Arc::new(deps_core::HttpCache::new());
1398 let ecosystem = PypiEcosystem::new(cache);
1399 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1400
1401 let content = r#"[project]
1402name = "test"
1403"#;
1404
1405 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1406 let position = Position {
1407 line: 0,
1408 character: 0,
1409 };
1410 let cached_versions = HashMap::new();
1411 let resolved_versions = HashMap::new();
1412
1413 let hover = ecosystem
1414 .generate_hover(
1415 parse_result.as_ref(),
1416 position,
1417 VersionData::new(&cached_versions, &resolved_versions),
1418 deps_core::FreshnessSettings::default(),
1419 )
1420 .await;
1421
1422 assert!(hover.is_none());
1423 }
1424
1425 #[tokio::test]
1426 async fn test_generate_code_actions_no_actions() {
1427 let cache = Arc::new(deps_core::HttpCache::new());
1428 let ecosystem = PypiEcosystem::new(cache);
1429 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1430
1431 let content = r#"[project]
1432name = "test"
1433"#;
1434
1435 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1436 let position = Position {
1437 line: 0,
1438 character: 0,
1439 };
1440 let cached_versions = HashMap::new();
1441 let resolved_versions = HashMap::new();
1442 let actions = ecosystem
1443 .generate_code_actions(
1444 parse_result.as_ref(),
1445 position,
1446 &uri,
1447 VersionData::new(&cached_versions, &resolved_versions),
1448 content,
1449 )
1450 .await;
1451
1452 assert!(actions.is_empty());
1453 }
1454
1455 #[tokio::test]
1456 async fn test_generate_diagnostics_no_dependencies() {
1457 let cache = Arc::new(deps_core::HttpCache::new());
1458 let ecosystem = PypiEcosystem::new(cache);
1459 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1460
1461 let content = r#"[project]
1462name = "test"
1463dependencies = []
1464"#;
1465
1466 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1467 let cached_versions = HashMap::new();
1468 let resolved_versions = HashMap::new();
1469
1470 let diagnostics = ecosystem
1471 .generate_diagnostics(
1472 parse_result.as_ref(),
1473 VersionData::new(&cached_versions, &resolved_versions),
1474 &uri,
1475 deps_core::FreshnessSettings::default(),
1476 deps_core::DiagnosticSeverities::default(),
1477 )
1478 .await;
1479
1480 assert!(diagnostics.is_empty());
1481 }
1482
1483 #[tokio::test]
1484 async fn test_complete_versions_empty_prefix() {
1485 let cache = Arc::new(deps_core::HttpCache::new());
1486 let ecosystem = PypiEcosystem::new(cache);
1487 let parse_result =
1488 parse_result_with_dependency("nonexistent-package", DependencySource::Registry);
1489
1490 let results = ecosystem
1492 .complete_versions(
1493 &parse_result,
1494 DEP_POSITION,
1495 "",
1496 deps_core::FreshnessSettings::default(),
1497 )
1498 .await;
1499 assert!(results.is_empty());
1501 }
1502
1503 #[tokio::test]
1504 async fn test_complete_versions_with_tilde_operator() {
1505 let cache = Arc::new(deps_core::HttpCache::new());
1506 let ecosystem = PypiEcosystem::new(cache);
1507 let parse_result =
1508 parse_result_with_dependency("nonexistent-pkg", DependencySource::Registry);
1509
1510 let results = ecosystem
1512 .complete_versions(
1513 &parse_result,
1514 DEP_POSITION,
1515 "~=2.0",
1516 deps_core::FreshnessSettings::default(),
1517 )
1518 .await;
1519 assert!(results.is_empty());
1520 }
1521
1522 #[tokio::test]
1523 async fn test_complete_versions_with_not_equal_operator() {
1524 let cache = Arc::new(deps_core::HttpCache::new());
1525 let ecosystem = PypiEcosystem::new(cache);
1526 let parse_result =
1527 parse_result_with_dependency("nonexistent-pkg", DependencySource::Registry);
1528
1529 let results = ecosystem
1531 .complete_versions(
1532 &parse_result,
1533 DEP_POSITION,
1534 "!=2.0",
1535 deps_core::FreshnessSettings::default(),
1536 )
1537 .await;
1538 assert!(results.is_empty());
1539 }
1540
1541 mod poetry_lockfile_regression_tests {
1548 use super::*;
1549 use crate::lockfile::PypiLockParser;
1550 use deps_core::PackageName;
1551 use deps_core::lockfile::LockFileProvider;
1552
1553 struct EmptyOkRegistry;
1558
1559 impl deps_core::Registry for EmptyOkRegistry {
1560 fn get_versions<'a>(
1561 &'a self,
1562 _name: &'a PackageName,
1563 ) -> deps_core::ecosystem::BoxFuture<
1564 'a,
1565 deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
1566 > {
1567 Box::pin(async move { Ok(Vec::new()) })
1568 }
1569
1570 fn get_latest_matching<'a>(
1571 &'a self,
1572 _name: &'a PackageName,
1573 _req: &'a deps_core::VersionReq,
1574 ) -> deps_core::ecosystem::BoxFuture<
1575 'a,
1576 deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
1577 > {
1578 Box::pin(async move { Ok(None) })
1579 }
1580
1581 fn search<'a>(
1582 &'a self,
1583 _query: &'a str,
1584 _limit: usize,
1585 ) -> deps_core::ecosystem::BoxFuture<
1586 'a,
1587 deps_core::error::Result<Vec<Box<dyn deps_core::Metadata>>>,
1588 > {
1589 Box::pin(async move { Ok(Vec::new()) })
1590 }
1591
1592 fn as_any(&self) -> &dyn std::any::Any {
1593 self
1594 }
1595 }
1596
1597 #[tokio::test]
1598 async fn test_poetry_table_key_dotted_name_resolves_against_lockfile() {
1599 let toml = "[tool.poetry.dependencies]\n\"zope.interface\" = \"^5.0\"\n";
1600 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
1601 let parser = PypiParser::new();
1602 let parse_result = parser.parse_content(toml, &uri).unwrap();
1603
1604 assert_eq!(parse_result.dependencies[0].name, "zope.interface");
1608 let dep_position = parse_result.dependencies[0].name_range.start;
1609
1610 let lockfile_content = "[[package]]\nname = \"zope-interface\"\nversion = \"5.2.0\"\n";
1617 let temp_dir = tempfile::tempdir().unwrap();
1618 let lockfile_path = temp_dir.path().join("poetry.lock");
1619 std::fs::write(&lockfile_path, lockfile_content).unwrap();
1620
1621 let lock_parser = PypiLockParser;
1622 let resolved_packages = lock_parser.parse_lockfile(&lockfile_path).await.unwrap();
1623 let resolved_versions: HashMap<PackageName, deps_core::ConcreteVersion> =
1624 resolved_packages
1625 .iter()
1626 .map(|(name, pkg)| {
1627 (PackageName::new(name.as_str()), pkg.version.clone().into())
1628 })
1629 .collect();
1630 assert!(resolved_versions.contains_key("zope-interface"));
1633
1634 let cached_versions: HashMap<PackageName, deps_core::PackageVersions> = HashMap::new();
1635 let versions = VersionData::new(&cached_versions, &resolved_versions);
1636 let formatter = PypiFormatter;
1637
1638 let hover = deps_core::lsp_helpers::generate_hover(
1639 &parse_result,
1640 dep_position,
1641 versions,
1642 &EmptyOkRegistry,
1643 &formatter,
1644 deps_core::FreshnessSettings::default(),
1645 deps_core::PublishTime::now(),
1646 )
1647 .await
1648 .expect("hover should be produced for a dependency at its name position");
1649
1650 let markdown = match hover.contents {
1651 tower_lsp_server::ls_types::HoverContents::Markup(m) => m.value,
1652 _ => panic!("expected Markup hover contents"),
1653 };
1654 assert!(
1655 markdown.contains("**Current**") && markdown.contains("5.2.0"),
1656 "hover should render the resolved lock file version: {markdown}"
1657 );
1658
1659 let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
1660 &parse_result,
1661 versions,
1662 &formatter,
1663 parse_result.uri(),
1664 deps_core::FreshnessSettings::default(),
1665 deps_core::DiagnosticSeverities::default(),
1666 deps_core::PublishTime::now(),
1667 );
1668 assert!(
1669 diagnostics
1670 .iter()
1671 .all(|d| !d.message.contains("Unknown package")),
1672 "no 'Unknown package' diagnostic should be emitted: {diagnostics:?}"
1673 );
1674 }
1675 }
1676
1677 fn parsed_dependencies(
1680 result: &dyn ParseResultTrait,
1681 ) -> Vec<(String, deps_core::parser::DependencySource)> {
1682 result
1683 .dependencies()
1684 .into_iter()
1685 .map(|d| (d.name().to_string(), d.source()))
1686 .collect()
1687 }
1688
1689 #[tokio::test]
1692 async fn test_parse_manifest_no_declaration_registers_nothing() {
1693 let cache = Arc::new(deps_core::HttpCache::new());
1694 let ecosystem = PypiEcosystem::new(cache);
1695 let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1696
1697 let result = ecosystem
1698 .parse_manifest("requests==2.31.0\n", &uri)
1699 .await
1700 .unwrap();
1701
1702 for (_, source) in parsed_dependencies(result.as_ref()) {
1703 assert_eq!(source, DependencySource::Registry);
1704 }
1705 }
1706
1707 #[tokio::test]
1717 async fn test_parse_manifest_blocked_extra_does_not_break_chain_with_valid_primary() {
1718 let cache = Arc::new(deps_core::HttpCache::new());
1719 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1720 deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly,
1721 ));
1722 let registry = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1723 let ecosystem = PypiEcosystem::with_policy(Arc::clone(®istry), policy);
1724 let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1725
1726 let content = "--index-url https://pypi.mycorp.example/simple\n\
1727 --extra-index-url https://10.0.0.5/simple\n\
1728 requests==2.31.0\n";
1729 let result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1730
1731 let deps = parsed_dependencies(result.as_ref());
1732 let (_, source) = deps.iter().find(|(name, _)| name == "requests").unwrap();
1733 let DependencySource::AlternateRegistry { index, .. } = source else {
1734 panic!("expected AlternateRegistry, got {source:?}");
1735 };
1736 assert!(registry.alternate_client(index).is_some());
1740 }
1741
1742 #[tokio::test]
1748 async fn test_parse_manifest_all_extras_blocked_degrades_to_plain_registry() {
1749 let cache = Arc::new(deps_core::HttpCache::new());
1750 let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1751 deps_core::net_policy::WorkspaceRegistryAccess::Off,
1752 ));
1753 let registry = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
1754 let ecosystem = PypiEcosystem::with_policy(Arc::clone(®istry), policy);
1755 let uri = deps_core::test_util::test_uri("/project/requirements.txt");
1756
1757 let content = "--extra-index-url https://extra.example/simple\nrequests==2.31.0\n";
1758 let result = ecosystem.parse_manifest(content, &uri).await.unwrap();
1759
1760 let deps = parsed_dependencies(result.as_ref());
1761 assert_eq!(deps.len(), 1);
1762 assert_eq!(deps[0].1, DependencySource::Registry);
1763
1764 let downcast = result
1766 .as_any()
1767 .downcast_ref::<crate::parser::ParseResult>()
1768 .unwrap();
1769 assert!(downcast.resolved_chains.is_empty());
1770 }
1771}